diff --git a/agent/agent.go b/agent/agent.go index daaf2beb7b6e7..2c209fe602d36 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -17,6 +17,7 @@ import ( "os/exec" "os/user" "path/filepath" + "reflect" "runtime" "sort" "strconv" @@ -60,7 +61,7 @@ const ( // MagicSSHSessionTypeEnvironmentVariable is used to track the purpose behind an SSH connection. // This is stripped from any commands being executed, and is counted towards connection stats. - MagicSSHSessionTypeEnvironmentVariable = "__CODER_SSH_SESSION_TYPE" + MagicSSHSessionTypeEnvironmentVariable = "CODER_SSH_SESSION_TYPE" // MagicSSHSessionTypeVSCode is set in the SSH config by the VS Code extension to identify itself. MagicSSHSessionTypeVSCode = "vscode" // MagicSSHSessionTypeJetBrains is set in the SSH config by the JetBrains extension to identify itself. @@ -122,9 +123,7 @@ func New(options Options) io.Closer { tempDir: options.TempDir, lifecycleUpdate: make(chan struct{}, 1), lifecycleReported: make(chan codersdk.WorkspaceAgentLifecycle, 1), - // TODO: This is a temporary hack to make tests not flake. - // @kylecarbs has a better solution in here: https://github.com/coder/coder/pull/6469 - connStatsChan: make(chan *agentsdk.Stats, 8), + connStatsChan: make(chan *agentsdk.Stats, 1), } a.init(ctx) return a @@ -159,11 +158,8 @@ type agent struct { network *tailnet.Conn connStatsChan chan *agentsdk.Stats + latestStat atomic.Pointer[agentsdk.Stats] - statRxPackets atomic.Int64 - statRxBytes atomic.Int64 - statTxPackets atomic.Int64 - statTxBytes atomic.Int64 connCountVSCode atomic.Int64 connCountJetBrains atomic.Int64 connCountReconnectingPTY atomic.Int64 @@ -905,10 +901,13 @@ func (a *agent) handleSSHSession(session ssh.Session) (retErr error) { switch magicType { case MagicSSHSessionTypeVSCode: a.connCountVSCode.Add(1) + defer a.connCountVSCode.Add(-1) case MagicSSHSessionTypeJetBrains: a.connCountJetBrains.Add(1) + defer a.connCountJetBrains.Add(-1) case "": a.connCountSSHSession.Add(1) + defer a.connCountSSHSession.Add(-1) default: a.logger.Warn(ctx, "invalid magic ssh session type specified", slog.F("type", magicType)) } @@ -1012,6 +1011,7 @@ func (a *agent) handleReconnectingPTY(ctx context.Context, logger slog.Logger, m defer conn.Close() a.connCountReconnectingPTY.Add(1) + defer a.connCountReconnectingPTY.Add(-1) connectionID := uuid.NewString() logger = logger.With(slog.F("id", msg.ID), slog.F("connection_id", connectionID)) @@ -1210,18 +1210,15 @@ func (a *agent) startReportingConnectionStats(ctx context.Context) { ConnectionCount: int64(len(networkStats)), ConnectionsByProto: map[string]int64{}, } - // Tailscale resets counts on every report! - // We'd rather have these compound, like Linux does! for conn, counts := range networkStats { stats.ConnectionsByProto[conn.Proto.String()]++ - stats.RxBytes = a.statRxBytes.Add(int64(counts.RxBytes)) - stats.RxPackets = a.statRxPackets.Add(int64(counts.RxPackets)) - stats.TxBytes = a.statTxBytes.Add(int64(counts.TxBytes)) - stats.TxPackets = a.statTxPackets.Add(int64(counts.TxPackets)) + stats.RxBytes += int64(counts.RxBytes) + stats.RxPackets += int64(counts.RxPackets) + stats.TxBytes += int64(counts.TxBytes) + stats.TxPackets += int64(counts.TxPackets) } - // Tailscale's connection stats are not cumulative, but it makes no sense to make - // ours temporary. + // The count of active sessions. stats.SessionCountSSH = a.connCountSSHSession.Load() stats.SessionCountVSCode = a.connCountVSCode.Load() stats.SessionCountJetBrains = a.connCountJetBrains.Load() @@ -1270,10 +1267,16 @@ func (a *agent) startReportingConnectionStats(ctx context.Context) { // Convert from microseconds to milliseconds. stats.ConnectionMedianLatencyMS /= 1000 + lastStat := a.latestStat.Load() + if lastStat != nil && reflect.DeepEqual(lastStat, stats) { + a.logger.Info(ctx, "skipping stat because nothing changed") + return + } + a.latestStat.Store(stats) + select { case a.connStatsChan <- stats: - default: - a.logger.Warn(ctx, "network stat dropped") + case <-a.closed: } } diff --git a/agent/agent_test.go b/agent/agent_test.go index a35f872288dc3..9e2ff33d2d5c2 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -68,7 +68,10 @@ func TestAgent_Stats_SSH(t *testing.T) { session, err := sshClient.NewSession() require.NoError(t, err) defer session.Close() - require.NoError(t, session.Run("echo test")) + stdin, err := session.StdinPipe() + require.NoError(t, err) + err = session.Shell() + require.NoError(t, err) var s *agentsdk.Stats require.Eventuallyf(t, func() bool { @@ -78,6 +81,9 @@ func TestAgent_Stats_SSH(t *testing.T) { }, testutil.WaitLong, testutil.IntervalFast, "never saw stats: %+v", s, ) + _ = stdin.Close() + err = session.Wait() + require.NoError(t, err) } func TestAgent_Stats_ReconnectingPTY(t *testing.T) { @@ -112,43 +118,69 @@ func TestAgent_Stats_ReconnectingPTY(t *testing.T) { func TestAgent_Stats_Magic(t *testing.T) { t.Parallel() + t.Run("StripsEnvironmentVariable", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + //nolint:dogsled + conn, _, _, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + sshClient, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshClient.Close() + session, err := sshClient.NewSession() + require.NoError(t, err) + session.Setenv(agent.MagicSSHSessionTypeEnvironmentVariable, agent.MagicSSHSessionTypeVSCode) + defer session.Close() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - //nolint:dogsled - conn, _, stats, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) - sshClient, err := conn.SSHClient(ctx) - require.NoError(t, err) - defer sshClient.Close() - session, err := sshClient.NewSession() - require.NoError(t, err) - session.Setenv(agent.MagicSSHSessionTypeEnvironmentVariable, agent.MagicSSHSessionTypeVSCode) - defer session.Close() - - command := "sh -c 'echo $" + agent.MagicSSHSessionTypeEnvironmentVariable + "'" - expected := "" - if runtime.GOOS == "windows" { - expected = "%" + agent.MagicSSHSessionTypeEnvironmentVariable + "%" - command = "cmd.exe /c echo " + expected - } - output, err := session.Output(command) - require.NoError(t, err) - require.Equal(t, expected, strings.TrimSpace(string(output))) - var s *agentsdk.Stats - require.Eventuallyf(t, func() bool { - var ok bool - s, ok = <-stats - return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && - // Ensure that the connection didn't count as a "normal" SSH session. - // This was a special one, so it should be labeled specially in the stats! - s.SessionCountVSCode == 1 && - // Ensure that connection latency is being counted! - // If it isn't, it's set to -1. - s.ConnectionMedianLatencyMS >= 0 - }, testutil.WaitLong, testutil.IntervalFast, - "never saw stats: %+v", s, - ) + command := "sh -c 'echo $" + agent.MagicSSHSessionTypeEnvironmentVariable + "'" + expected := "" + if runtime.GOOS == "windows" { + expected = "%" + agent.MagicSSHSessionTypeEnvironmentVariable + "%" + command = "cmd.exe /c echo " + expected + } + output, err := session.Output(command) + require.NoError(t, err) + require.Equal(t, expected, strings.TrimSpace(string(output))) + }) + t.Run("Tracks", func(t *testing.T) { + t.Parallel() + if runtime.GOOS == "window" { + t.Skip("Sleeping for infinity doesn't work on Windows") + } + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + //nolint:dogsled + conn, _, stats, _, _ := setupAgent(t, agentsdk.Metadata{}, 0) + sshClient, err := conn.SSHClient(ctx) + require.NoError(t, err) + defer sshClient.Close() + session, err := sshClient.NewSession() + require.NoError(t, err) + session.Setenv(agent.MagicSSHSessionTypeEnvironmentVariable, agent.MagicSSHSessionTypeVSCode) + defer session.Close() + stdin, err := session.StdinPipe() + require.NoError(t, err) + err = session.Shell() + require.NoError(t, err) + var s *agentsdk.Stats + require.Eventuallyf(t, func() bool { + var ok bool + s, ok = <-stats + return ok && s.ConnectionCount > 0 && s.RxBytes > 0 && s.TxBytes > 0 && + // Ensure that the connection didn't count as a "normal" SSH session. + // This was a special one, so it should be labeled specially in the stats! + s.SessionCountVSCode == 1 && + // Ensure that connection latency is being counted! + // If it isn't, it's set to -1. + s.ConnectionMedianLatencyMS >= 0 + }, testutil.WaitLong, testutil.IntervalFast, + "never saw stats: %+v", s, + ) + // The shell will automatically exit if there is no stdin! + _ = stdin.Close() + err = session.Wait() + require.NoError(t, err) + }) } func TestAgent_SessionExec(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ddfc62513ffbe..966bff2bf4d45 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -304,31 +304,6 @@ const docTemplate = `{ } } }, - "/config/deployment": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": [ - "application/json" - ], - "tags": [ - "General" - ], - "summary": "Get deployment config", - "operationId": "get-deployment-config", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.DeploymentConfig" - } - } - } - } - }, "/csp/reports": { "post": { "security": [ @@ -384,6 +359,56 @@ const docTemplate = `{ } } }, + "/deployment/config": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Get deployment config", + "operationId": "get-deployment-config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.DeploymentConfig" + } + } + } + } + }, + "/deployment/stats": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Get deployment stats", + "operationId": "get-deployment-stats", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.DeploymentStats" + } + } + } + } + }, "/entitlements": { "get": { "security": [ @@ -6454,6 +6479,32 @@ const docTemplate = `{ } } }, + "codersdk.DeploymentStats": { + "type": "object", + "properties": { + "aggregated_from": { + "description": "AggregatedFrom is the time in which stats are aggregated from.\nThis might be back in time a specific duration or interval.", + "type": "string", + "format": "date-time" + }, + "collected_at": { + "description": "CollectedAt is the time in which stats are collected at.", + "type": "string", + "format": "date-time" + }, + "next_update_at": { + "description": "NextUpdateAt is the time when the next batch of stats will\nbe updated.", + "type": "string", + "format": "date-time" + }, + "session_count": { + "$ref": "#/definitions/codersdk.SessionCountDeploymentStats" + }, + "workspaces": { + "$ref": "#/definitions/codersdk.WorkspaceDeploymentStats" + } + } + }, "codersdk.DeploymentValues": { "type": "object", "properties": { @@ -7614,6 +7665,23 @@ const docTemplate = `{ } } }, + "codersdk.SessionCountDeploymentStats": { + "type": "object", + "properties": { + "jetbrains": { + "type": "integer" + }, + "reconnecting_pty": { + "type": "integer" + }, + "ssh": { + "type": "integer" + }, + "vscode": { + "type": "integer" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -8751,6 +8819,46 @@ const docTemplate = `{ } } }, + "codersdk.WorkspaceConnectionLatencyMS": { + "type": "object", + "properties": { + "p50": { + "type": "number" + }, + "p95": { + "type": "number" + } + } + }, + "codersdk.WorkspaceDeploymentStats": { + "type": "object", + "properties": { + "building": { + "type": "integer" + }, + "connection_latency_ms": { + "$ref": "#/definitions/codersdk.WorkspaceConnectionLatencyMS" + }, + "failed": { + "type": "integer" + }, + "pending": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "rx_bytes": { + "type": "integer" + }, + "stopped": { + "type": "integer" + }, + "tx_bytes": { + "type": "integer" + } + } + }, "codersdk.WorkspaceQuota": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 900b10e16afc0..b64e2cd4d79d2 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -258,27 +258,6 @@ } } }, - "/config/deployment": { - "get": { - "security": [ - { - "CoderSessionToken": [] - } - ], - "produces": ["application/json"], - "tags": ["General"], - "summary": "Get deployment config", - "operationId": "get-deployment-config", - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/codersdk.DeploymentConfig" - } - } - } - } - }, "/csp/reports": { "post": { "security": [ @@ -326,6 +305,48 @@ } } }, + "/deployment/config": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Get deployment config", + "operationId": "get-deployment-config", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.DeploymentConfig" + } + } + } + } + }, + "/deployment/stats": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Get deployment stats", + "operationId": "get-deployment-stats", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.DeploymentStats" + } + } + } + } + }, "/entitlements": { "get": { "security": [ @@ -5758,6 +5779,32 @@ } } }, + "codersdk.DeploymentStats": { + "type": "object", + "properties": { + "aggregated_from": { + "description": "AggregatedFrom is the time in which stats are aggregated from.\nThis might be back in time a specific duration or interval.", + "type": "string", + "format": "date-time" + }, + "collected_at": { + "description": "CollectedAt is the time in which stats are collected at.", + "type": "string", + "format": "date-time" + }, + "next_update_at": { + "description": "NextUpdateAt is the time when the next batch of stats will\nbe updated.", + "type": "string", + "format": "date-time" + }, + "session_count": { + "$ref": "#/definitions/codersdk.SessionCountDeploymentStats" + }, + "workspaces": { + "$ref": "#/definitions/codersdk.WorkspaceDeploymentStats" + } + } + }, "codersdk.DeploymentValues": { "type": "object", "properties": { @@ -6829,6 +6876,23 @@ } } }, + "codersdk.SessionCountDeploymentStats": { + "type": "object", + "properties": { + "jetbrains": { + "type": "integer" + }, + "reconnecting_pty": { + "type": "integer" + }, + "ssh": { + "type": "integer" + }, + "vscode": { + "type": "integer" + } + } + }, "codersdk.SupportConfig": { "type": "object", "properties": { @@ -7886,6 +7950,46 @@ } } }, + "codersdk.WorkspaceConnectionLatencyMS": { + "type": "object", + "properties": { + "p50": { + "type": "number" + }, + "p95": { + "type": "number" + } + } + }, + "codersdk.WorkspaceDeploymentStats": { + "type": "object", + "properties": { + "building": { + "type": "integer" + }, + "connection_latency_ms": { + "$ref": "#/definitions/codersdk.WorkspaceConnectionLatencyMS" + }, + "failed": { + "type": "integer" + }, + "pending": { + "type": "integer" + }, + "running": { + "type": "integer" + }, + "rx_bytes": { + "type": "integer" + }, + "stopped": { + "type": "integer" + }, + "tx_bytes": { + "type": "integer" + } + } + }, "codersdk.WorkspaceQuota": { "type": "object", "properties": { diff --git a/coderd/coderd.go b/coderd/coderd.go index 2d80d9d877a00..b0edab9b782a2 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -236,7 +236,10 @@ func New(options *Options) *API { metricsCache := metricscache.New( options.Database, options.Logger.Named("metrics_cache"), - options.MetricsCacheRefreshInterval, + metricscache.Intervals{ + TemplateDAUs: options.MetricsCacheRefreshInterval, + DeploymentStats: options.AgentStatsRefreshInterval, + }, ) staticHandler := site.Handler(site.FS(), binFS, binHashes) @@ -392,15 +395,16 @@ func New(options *Options) *API { r.Post("/csp/reports", api.logReportCSPViolations) r.Get("/buildinfo", buildInfo) + r.Route("/deployment", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/config", api.deploymentValues) + r.Get("/stats", api.deploymentStats) + }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Get("/", api.handleExperimentsGet) }) r.Get("/updatecheck", api.updateCheck) - r.Route("/config", func(r chi.Router) { - r.Use(apiKeyMiddleware) - r.Get("/deployment", api.deploymentValues) - }) r.Route("/audit", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/database/dbauthz/system.go b/coderd/database/dbauthz/system.go index b290f68bc8428..e83b0b0771f9d 100644 --- a/coderd/database/dbauthz/system.go +++ b/coderd/database/dbauthz/system.go @@ -201,6 +201,14 @@ func (q *querier) DeleteOldWorkspaceAgentStats(ctx context.Context) error { return q.db.DeleteOldWorkspaceAgentStats(ctx) } +func (q *querier) GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAfter time.Time) (database.GetDeploymentWorkspaceAgentStatsRow, error) { + return q.db.GetDeploymentWorkspaceAgentStats(ctx, createdAfter) +} + +func (q *querier) GetDeploymentWorkspaceStats(ctx context.Context) (database.GetDeploymentWorkspaceStatsRow, error) { + return q.db.GetDeploymentWorkspaceStats(ctx) +} + func (q *querier) GetParameterSchemasCreatedAfter(ctx context.Context, createdAt time.Time) ([]database.ParameterSchema, error) { return q.db.GetParameterSchemasCreatedAfter(ctx, createdAt) } diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 9d57785915c32..255e378beb4ad 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -312,6 +312,53 @@ func (*fakeQuerier) DeleteOldWorkspaceAgentStats(_ context.Context) error { return nil } +func (q *fakeQuerier) GetDeploymentWorkspaceAgentStats(_ context.Context, createdAfter time.Time) (database.GetDeploymentWorkspaceAgentStatsRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + agentStatsCreatedAfter := make([]database.WorkspaceAgentStat, 0) + for _, agentStat := range q.workspaceAgentStats { + if agentStat.CreatedAt.After(createdAfter) { + agentStatsCreatedAfter = append(agentStatsCreatedAfter, agentStat) + } + } + + latestAgentStats := map[uuid.UUID]database.WorkspaceAgentStat{} + for _, agentStat := range q.workspaceAgentStats { + if agentStat.CreatedAt.After(createdAfter) { + latestAgentStats[agentStat.AgentID] = agentStat + } + } + + stat := database.GetDeploymentWorkspaceAgentStatsRow{} + for _, agentStat := range latestAgentStats { + stat.SessionCountVSCode += agentStat.SessionCountVSCode + stat.SessionCountJetBrains += agentStat.SessionCountJetBrains + stat.SessionCountReconnectingPTY += agentStat.SessionCountReconnectingPTY + stat.SessionCountSSH += agentStat.SessionCountSSH + } + + latencies := make([]float64, 0) + for _, agentStat := range agentStatsCreatedAfter { + stat.WorkspaceRxBytes += agentStat.RxBytes + stat.WorkspaceTxBytes += agentStat.TxBytes + latencies = append(latencies, agentStat.ConnectionMedianLatencyMS) + } + + tryPercentile := func(fs []float64, p float64) float64 { + if len(fs) == 0 { + return -1 + } + sort.Float64s(fs) + return fs[int(float64(len(fs))*p/100)] + } + + stat.WorkspaceConnectionLatency50 = tryPercentile(latencies, 50) + stat.WorkspaceConnectionLatency95 = tryPercentile(latencies, 95) + + return stat, nil +} + func (q *fakeQuerier) InsertWorkspaceAgentStat(_ context.Context, p database.InsertWorkspaceAgentStatParams) (database.WorkspaceAgentStat, error) { if err := validateDatabaseType(p); err != nil { return database.WorkspaceAgentStat{}, err @@ -1031,7 +1078,7 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } if arg.Status != "" { - build, err := q.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) if err != nil { return nil, xerrors.Errorf("get latest build: %w", err) } @@ -1120,7 +1167,7 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } if arg.HasAgent != "" { - build, err := q.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) if err != nil { return nil, xerrors.Errorf("get latest build: %w", err) } @@ -1426,10 +1473,14 @@ func (q *fakeQuerier) GetWorkspaceBuildByJobID(_ context.Context, jobID uuid.UUI return database.WorkspaceBuild{}, sql.ErrNoRows } -func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { +func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { q.mutex.RLock() defer q.mutex.RUnlock() + return q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspaceID) +} + +func (q *fakeQuerier) getLatestWorkspaceBuildByWorkspaceIDNoLock(_ context.Context, workspaceID uuid.UUID) (database.WorkspaceBuild, error) { var row database.WorkspaceBuild var buildNum int32 = -1 for _, workspaceBuild := range q.workspaceBuilds { @@ -3609,6 +3660,50 @@ func (q *fakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database. return sql.ErrNoRows } +func (q *fakeQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (database.GetDeploymentWorkspaceStatsRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + stat := database.GetDeploymentWorkspaceStatsRow{} + for _, workspace := range q.workspaces { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return stat, err + } + job, err := q.getProvisionerJobByIDNoLock(ctx, build.JobID) + if err != nil { + return stat, err + } + if !job.StartedAt.Valid { + stat.PendingWorkspaces++ + continue + } + if job.StartedAt.Valid && + !job.CanceledAt.Valid && + time.Since(job.UpdatedAt) <= 30*time.Second && + !job.CompletedAt.Valid { + stat.BuildingWorkspaces++ + continue + } + if job.CompletedAt.Valid && + !job.CanceledAt.Valid && + !job.Error.Valid { + if build.Transition == database.WorkspaceTransitionStart { + stat.RunningWorkspaces++ + } + if build.Transition == database.WorkspaceTransitionStop { + stat.StoppedWorkspaces++ + } + continue + } + if job.CanceledAt.Valid || job.Error.Valid { + stat.FailedWorkspaces++ + continue + } + } + return stat, nil +} + func (q *fakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { if err := validateDatabaseType(arg); err != nil { return err diff --git a/coderd/database/dbgen/generator.go b/coderd/database/dbgen/generator.go index 69e3dc76253ed..42dbb599dadcb 100644 --- a/coderd/database/dbgen/generator.go +++ b/coderd/database/dbgen/generator.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "database/sql" "encoding/hex" + "encoding/json" "fmt" "net" "testing" @@ -437,3 +438,30 @@ func ParameterValue(t testing.TB, db database.Store, seed database.ParameterValu require.NoError(t, err, "insert parameter value") return scheme } + +func WorkspaceAgentStat(t testing.TB, db database.Store, orig database.WorkspaceAgentStat) database.WorkspaceAgentStat { + if orig.ConnectionsByProto == nil { + orig.ConnectionsByProto = json.RawMessage([]byte("{}")) + } + scheme, err := db.InsertWorkspaceAgentStat(context.Background(), database.InsertWorkspaceAgentStatParams{ + ID: takeFirst(orig.ID, uuid.New()), + CreatedAt: takeFirst(orig.CreatedAt, database.Now()), + UserID: takeFirst(orig.UserID, uuid.New()), + TemplateID: takeFirst(orig.TemplateID, uuid.New()), + WorkspaceID: takeFirst(orig.WorkspaceID, uuid.New()), + AgentID: takeFirst(orig.AgentID, uuid.New()), + ConnectionsByProto: orig.ConnectionsByProto, + ConnectionCount: takeFirst(orig.ConnectionCount, 0), + RxPackets: takeFirst(orig.RxPackets, 0), + RxBytes: takeFirst(orig.RxBytes, 0), + TxPackets: takeFirst(orig.TxPackets, 0), + TxBytes: takeFirst(orig.TxBytes, 0), + SessionCountVSCode: takeFirst(orig.SessionCountVSCode, 0), + SessionCountJetBrains: takeFirst(orig.SessionCountJetBrains, 0), + SessionCountReconnectingPTY: takeFirst(orig.SessionCountReconnectingPTY, 0), + SessionCountSSH: takeFirst(orig.SessionCountSSH, 0), + ConnectionMedianLatencyMS: takeFirst(orig.ConnectionMedianLatencyMS, 0), + }) + require.NoError(t, err, "insert workspace agent stat") + return scheme +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 1e906e26273b5..06870724bd1ce 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -485,7 +485,7 @@ CREATE TABLE workspace_agent_stats ( rx_bytes bigint DEFAULT 0 NOT NULL, tx_packets bigint DEFAULT 0 NOT NULL, tx_bytes bigint DEFAULT 0 NOT NULL, - connection_median_latency_ms bigint DEFAULT '-1'::integer NOT NULL, + connection_median_latency_ms double precision DEFAULT '-1'::integer NOT NULL, session_count_vscode bigint DEFAULT 0 NOT NULL, session_count_jetbrains bigint DEFAULT 0 NOT NULL, session_count_reconnecting_pty bigint DEFAULT 0 NOT NULL, diff --git a/coderd/database/generate.sh b/coderd/database/generate.sh index e70bbae939ff6..5669f121a57e0 100755 --- a/coderd/database/generate.sh +++ b/coderd/database/generate.sh @@ -18,7 +18,7 @@ SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") # Dump the updated schema (use make to utilize caching). make -C ../.. --no-print-directory coderd/database/dump.sql # The logic below depends on the exact version being correct :( - go run github.com/kyleconroy/sqlc/cmd/sqlc@v1.16.0 generate + sqlc generate first=true for fi in queries/*.sql.go; do diff --git a/coderd/database/migrations/000108_workspace_agent_stats_connection_latency.down.sql b/coderd/database/migrations/000108_workspace_agent_stats_connection_latency.down.sql new file mode 100644 index 0000000000000..edbde53b237b0 --- /dev/null +++ b/coderd/database/migrations/000108_workspace_agent_stats_connection_latency.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_agent_stats ALTER COLUMN connection_median_latency_ms TYPE bigint; diff --git a/coderd/database/migrations/000108_workspace_agent_stats_connection_latency.up.sql b/coderd/database/migrations/000108_workspace_agent_stats_connection_latency.up.sql new file mode 100644 index 0000000000000..c8ccaa7768015 --- /dev/null +++ b/coderd/database/migrations/000108_workspace_agent_stats_connection_latency.up.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_agent_stats ALTER COLUMN connection_median_latency_ms TYPE FLOAT; diff --git a/coderd/database/migrations/testdata/fixtures/000106_pre_workspace_agent_stats_connection_latency.up.sql b/coderd/database/migrations/testdata/fixtures/000106_pre_workspace_agent_stats_connection_latency.up.sql new file mode 100644 index 0000000000000..daf39cb2660d8 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000106_pre_workspace_agent_stats_connection_latency.up.sql @@ -0,0 +1,17 @@ +INSERT INTO workspace_agent_stats ( + id, + created_at, + user_id, + agent_id, + workspace_id, + template_id, + connection_median_latency_ms +) VALUES ( + gen_random_uuid(), + NOW(), + gen_random_uuid(), + gen_random_uuid(), + gen_random_uuid(), + gen_random_uuid(), + 1::bigint +); diff --git a/coderd/database/migrations/testdata/fixtures/000107_post_workspace_agent_stats_connection_latency.up.sql b/coderd/database/migrations/testdata/fixtures/000107_post_workspace_agent_stats_connection_latency.up.sql new file mode 100644 index 0000000000000..58b8f248b9c39 --- /dev/null +++ b/coderd/database/migrations/testdata/fixtures/000107_post_workspace_agent_stats_connection_latency.up.sql @@ -0,0 +1,17 @@ +INSERT INTO workspace_agent_stats ( + id, + created_at, + user_id, + agent_id, + workspace_id, + template_id, + connection_median_latency_ms +) VALUES ( + gen_random_uuid(), + NOW(), + gen_random_uuid(), + gen_random_uuid(), + gen_random_uuid(), + gen_random_uuid(), + 0.5::float +); diff --git a/coderd/database/models.go b/coderd/database/models.go index 0b4b81b242079..d68ada5371118 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1582,7 +1582,7 @@ type WorkspaceAgentStat struct { RxBytes int64 `db:"rx_bytes" json:"rx_bytes"` TxPackets int64 `db:"tx_packets" json:"tx_packets"` TxBytes int64 `db:"tx_bytes" json:"tx_bytes"` - ConnectionMedianLatencyMS int64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` + ConnectionMedianLatencyMS float64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` SessionCountVSCode int64 `db:"session_count_vscode" json:"session_count_vscode"` SessionCountJetBrains int64 `db:"session_count_jetbrains" json:"session_count_jetbrains"` SessionCountReconnectingPTY int64 `db:"session_count_reconnecting_pty" json:"session_count_reconnecting_pty"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index e90e2636a39d8..14a0f2d7053f2 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -53,6 +53,8 @@ type sqlcQuerier interface { GetDERPMeshKey(ctx context.Context) (string, error) GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUsRow, error) GetDeploymentID(ctx context.Context) (string, error) + GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) + GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) // This will never count deleted users. diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go new file mode 100644 index 0000000000000..5e8def562c16d --- /dev/null +++ b/coderd/database/querier_test.go @@ -0,0 +1,88 @@ +//go:build linux + +package database_test + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/coderd/database/migrations" +) + +func TestGetDeploymentWorkspaceAgentStats(t *testing.T) { + t.Parallel() + if testing.Short() { + t.SkipNow() + } + t.Run("Aggregates", func(t *testing.T) { + t.Parallel() + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + ctx := context.Background() + dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ + TxBytes: 1, + RxBytes: 1, + ConnectionMedianLatencyMS: 1, + SessionCountVSCode: 1, + }) + dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ + TxBytes: 1, + RxBytes: 1, + ConnectionMedianLatencyMS: 2, + SessionCountVSCode: 1, + }) + stats, err := db.GetDeploymentWorkspaceAgentStats(ctx, database.Now().Add(-time.Hour)) + require.NoError(t, err) + + require.Equal(t, int64(2), stats.WorkspaceTxBytes) + require.Equal(t, int64(2), stats.WorkspaceRxBytes) + require.Equal(t, 1.5, stats.WorkspaceConnectionLatency50) + require.Equal(t, 1.95, stats.WorkspaceConnectionLatency95) + require.Equal(t, int64(2), stats.SessionCountVSCode) + }) + + t.Run("GroupsByAgentID", func(t *testing.T) { + t.Parallel() + + sqlDB := testSQLDB(t) + err := migrations.Up(sqlDB) + require.NoError(t, err) + db := database.New(sqlDB) + ctx := context.Background() + agentID := uuid.New() + insertTime := database.Now() + dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ + CreatedAt: insertTime.Add(-time.Second), + AgentID: agentID, + TxBytes: 1, + RxBytes: 1, + ConnectionMedianLatencyMS: 1, + SessionCountVSCode: 1, + }) + dbgen.WorkspaceAgentStat(t, db, database.WorkspaceAgentStat{ + // Ensure this stat is newer! + CreatedAt: insertTime, + AgentID: agentID, + TxBytes: 1, + RxBytes: 1, + ConnectionMedianLatencyMS: 2, + SessionCountVSCode: 1, + }) + stats, err := db.GetDeploymentWorkspaceAgentStats(ctx, database.Now().Add(-time.Hour)) + require.NoError(t, err) + + require.Equal(t, int64(2), stats.WorkspaceTxBytes) + require.Equal(t, int64(2), stats.WorkspaceRxBytes) + require.Equal(t, 1.5, stats.WorkspaceConnectionLatency50) + require.Equal(t, 1.95, stats.WorkspaceConnectionLatency95) + require.Equal(t, int64(1), stats.SessionCountVSCode) + }) +} diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2e16a44cbbb7e..a7b9ae077b78d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5544,6 +5544,56 @@ func (q *sqlQuerier) GetDeploymentDAUs(ctx context.Context) ([]GetDeploymentDAUs return items, nil } +const getDeploymentWorkspaceAgentStats = `-- name: GetDeploymentWorkspaceAgentStats :one +WITH agent_stats AS ( + SELECT + coalesce(SUM(rx_bytes), 0)::bigint AS workspace_rx_bytes, + coalesce(SUM(tx_bytes), 0)::bigint AS workspace_tx_bytes, + coalesce((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_50, + coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95 + FROM workspace_agent_stats + WHERE workspace_agent_stats.created_at > $1 +), latest_agent_stats AS ( + SELECT + coalesce(SUM(session_count_vscode), 0)::bigint AS session_count_vscode, + coalesce(SUM(session_count_ssh), 0)::bigint AS session_count_ssh, + coalesce(SUM(session_count_jetbrains), 0)::bigint AS session_count_jetbrains, + coalesce(SUM(session_count_reconnecting_pty), 0)::bigint AS session_count_reconnecting_pty + FROM ( + SELECT id, created_at, user_id, agent_id, workspace_id, template_id, connections_by_proto, connection_count, rx_packets, rx_bytes, tx_packets, tx_bytes, connection_median_latency_ms, session_count_vscode, session_count_jetbrains, session_count_reconnecting_pty, session_count_ssh, ROW_NUMBER() OVER(PARTITION BY agent_id ORDER BY created_at DESC) AS rn + FROM workspace_agent_stats + ) AS a WHERE a.rn = 1 +) +SELECT workspace_rx_bytes, workspace_tx_bytes, workspace_connection_latency_50, workspace_connection_latency_95, session_count_vscode, session_count_ssh, session_count_jetbrains, session_count_reconnecting_pty FROM agent_stats, latest_agent_stats +` + +type GetDeploymentWorkspaceAgentStatsRow struct { + WorkspaceRxBytes int64 `db:"workspace_rx_bytes" json:"workspace_rx_bytes"` + WorkspaceTxBytes int64 `db:"workspace_tx_bytes" json:"workspace_tx_bytes"` + WorkspaceConnectionLatency50 float64 `db:"workspace_connection_latency_50" json:"workspace_connection_latency_50"` + WorkspaceConnectionLatency95 float64 `db:"workspace_connection_latency_95" json:"workspace_connection_latency_95"` + SessionCountVSCode int64 `db:"session_count_vscode" json:"session_count_vscode"` + SessionCountSSH int64 `db:"session_count_ssh" json:"session_count_ssh"` + SessionCountJetBrains int64 `db:"session_count_jetbrains" json:"session_count_jetbrains"` + SessionCountReconnectingPTY int64 `db:"session_count_reconnecting_pty" json:"session_count_reconnecting_pty"` +} + +func (q *sqlQuerier) GetDeploymentWorkspaceAgentStats(ctx context.Context, createdAt time.Time) (GetDeploymentWorkspaceAgentStatsRow, error) { + row := q.db.QueryRowContext(ctx, getDeploymentWorkspaceAgentStats, createdAt) + var i GetDeploymentWorkspaceAgentStatsRow + err := row.Scan( + &i.WorkspaceRxBytes, + &i.WorkspaceTxBytes, + &i.WorkspaceConnectionLatency50, + &i.WorkspaceConnectionLatency95, + &i.SessionCountVSCode, + &i.SessionCountSSH, + &i.SessionCountJetBrains, + &i.SessionCountReconnectingPTY, + ) + return i, err +} + const getTemplateDAUs = `-- name: GetTemplateDAUs :many SELECT (created_at at TIME ZONE 'UTC')::date as date, @@ -5629,7 +5679,7 @@ type InsertWorkspaceAgentStatParams struct { SessionCountJetBrains int64 `db:"session_count_jetbrains" json:"session_count_jetbrains"` SessionCountReconnectingPTY int64 `db:"session_count_reconnecting_pty" json:"session_count_reconnecting_pty"` SessionCountSSH int64 `db:"session_count_ssh" json:"session_count_ssh"` - ConnectionMedianLatencyMS int64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` + ConnectionMedianLatencyMS float64 `db:"connection_median_latency_ms" json:"connection_median_latency_ms"` } func (q *sqlQuerier) InsertWorkspaceAgentStat(ctx context.Context, arg InsertWorkspaceAgentStatParams) (WorkspaceAgentStat, error) { @@ -6843,6 +6893,90 @@ func (q *sqlQuerier) InsertWorkspaceResourceMetadata(ctx context.Context, arg In return items, nil } +const getDeploymentWorkspaceStats = `-- name: GetDeploymentWorkspaceStats :one +WITH workspaces_with_jobs AS ( + SELECT + latest_build.transition, latest_build.provisioner_job_id, latest_build.started_at, latest_build.updated_at, latest_build.canceled_at, latest_build.completed_at, latest_build.error FROM workspaces + LEFT JOIN LATERAL ( + SELECT + workspace_builds.transition, + provisioner_jobs.id AS provisioner_job_id, + provisioner_jobs.started_at, + provisioner_jobs.updated_at, + provisioner_jobs.canceled_at, + provisioner_jobs.completed_at, + provisioner_jobs.error + FROM + workspace_builds + LEFT JOIN + provisioner_jobs + ON + provisioner_jobs.id = workspace_builds.job_id + WHERE + workspace_builds.workspace_id = workspaces.id + ORDER BY + build_number DESC + LIMIT + 1 + ) latest_build ON TRUE +), pending_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + started_at IS NULL +), building_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + started_at IS NOT NULL AND + canceled_at IS NULL AND + updated_at - INTERVAL '30 seconds' < NOW() AND + completed_at IS NULL +), running_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + completed_at IS NOT NULL AND + canceled_at IS NULL AND + error IS NULL AND + transition = 'start'::workspace_transition +), failed_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + (canceled_at IS NOT NULL AND + error IS NOT NULL) OR + (completed_at IS NOT NULL AND + error IS NOT NULL) +), stopped_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + completed_at IS NOT NULL AND + canceled_at IS NULL AND + error IS NULL AND + transition = 'stop'::workspace_transition +) +SELECT + pending_workspaces.count AS pending_workspaces, + building_workspaces.count AS building_workspaces, + running_workspaces.count AS running_workspaces, + failed_workspaces.count AS failed_workspaces, + stopped_workspaces.count AS stopped_workspaces +FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspaces, stopped_workspaces +` + +type GetDeploymentWorkspaceStatsRow struct { + PendingWorkspaces int64 `db:"pending_workspaces" json:"pending_workspaces"` + BuildingWorkspaces int64 `db:"building_workspaces" json:"building_workspaces"` + RunningWorkspaces int64 `db:"running_workspaces" json:"running_workspaces"` + FailedWorkspaces int64 `db:"failed_workspaces" json:"failed_workspaces"` + StoppedWorkspaces int64 `db:"stopped_workspaces" json:"stopped_workspaces"` +} + +func (q *sqlQuerier) GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) { + row := q.db.QueryRowContext(ctx, getDeploymentWorkspaceStats) + var i GetDeploymentWorkspaceStatsRow + err := row.Scan( + &i.PendingWorkspaces, + &i.BuildingWorkspaces, + &i.RunningWorkspaces, + &i.FailedWorkspaces, + &i.StoppedWorkspaces, + ) + return i, err +} + const getWorkspaceByAgentID = `-- name: GetWorkspaceByAgentID :one SELECT id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at diff --git a/coderd/database/queries/workspaceagentstats.sql b/coderd/database/queries/workspaceagentstats.sql index 0aa5f131e1b10..1c309107faea0 100644 --- a/coderd/database/queries/workspaceagentstats.sql +++ b/coderd/database/queries/workspaceagentstats.sql @@ -51,3 +51,25 @@ ORDER BY -- name: DeleteOldWorkspaceAgentStats :exec DELETE FROM workspace_agent_stats WHERE created_at < NOW() - INTERVAL '30 days'; + +-- name: GetDeploymentWorkspaceAgentStats :one +WITH agent_stats AS ( + SELECT + coalesce(SUM(rx_bytes), 0)::bigint AS workspace_rx_bytes, + coalesce(SUM(tx_bytes), 0)::bigint AS workspace_tx_bytes, + coalesce((PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_50, + coalesce((PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY connection_median_latency_ms)), -1)::FLOAT AS workspace_connection_latency_95 + FROM workspace_agent_stats + WHERE workspace_agent_stats.created_at > $1 +), latest_agent_stats AS ( + SELECT + coalesce(SUM(session_count_vscode), 0)::bigint AS session_count_vscode, + coalesce(SUM(session_count_ssh), 0)::bigint AS session_count_ssh, + coalesce(SUM(session_count_jetbrains), 0)::bigint AS session_count_jetbrains, + coalesce(SUM(session_count_reconnecting_pty), 0)::bigint AS session_count_reconnecting_pty + FROM ( + SELECT *, ROW_NUMBER() OVER(PARTITION BY agent_id ORDER BY created_at DESC) AS rn + FROM workspace_agent_stats + ) AS a WHERE a.rn = 1 +) +SELECT * FROM agent_stats, latest_agent_stats; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 3beedc6932089..1694e5203b3c8 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -330,3 +330,65 @@ WHERE -- During build time, the template max TTL will still be used if the -- workspace TTL is NULL. AND ttl IS NOT NULL; + +-- name: GetDeploymentWorkspaceStats :one +WITH workspaces_with_jobs AS ( + SELECT + latest_build.* FROM workspaces + LEFT JOIN LATERAL ( + SELECT + workspace_builds.transition, + provisioner_jobs.id AS provisioner_job_id, + provisioner_jobs.started_at, + provisioner_jobs.updated_at, + provisioner_jobs.canceled_at, + provisioner_jobs.completed_at, + provisioner_jobs.error + FROM + workspace_builds + LEFT JOIN + provisioner_jobs + ON + provisioner_jobs.id = workspace_builds.job_id + WHERE + workspace_builds.workspace_id = workspaces.id + ORDER BY + build_number DESC + LIMIT + 1 + ) latest_build ON TRUE +), pending_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + started_at IS NULL +), building_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + started_at IS NOT NULL AND + canceled_at IS NULL AND + updated_at - INTERVAL '30 seconds' < NOW() AND + completed_at IS NULL +), running_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + completed_at IS NOT NULL AND + canceled_at IS NULL AND + error IS NULL AND + transition = 'start'::workspace_transition +), failed_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + (canceled_at IS NOT NULL AND + error IS NOT NULL) OR + (completed_at IS NOT NULL AND + error IS NOT NULL) +), stopped_workspaces AS ( + SELECT COUNT(*) AS count FROM workspaces_with_jobs WHERE + completed_at IS NOT NULL AND + canceled_at IS NULL AND + error IS NULL AND + transition = 'stop'::workspace_transition +) +SELECT + pending_workspaces.count AS pending_workspaces, + building_workspaces.count AS building_workspaces, + running_workspaces.count AS running_workspaces, + failed_workspaces.count AS failed_workspaces, + stopped_workspaces.count AS stopped_workspaces +FROM pending_workspaces, building_workspaces, running_workspaces, failed_workspaces, stopped_workspaces; diff --git a/coderd/deploymentconfig.go b/coderd/deployment.go similarity index 52% rename from coderd/deploymentconfig.go rename to coderd/deployment.go index c84d915f46c92..158b07c5bfbdd 100644 --- a/coderd/deploymentconfig.go +++ b/coderd/deployment.go @@ -14,7 +14,7 @@ import ( // @Produce json // @Tags General // @Success 200 {object} codersdk.DeploymentConfig -// @Router /config/deployment [get] +// @Router /deployment/config [get] func (api *API) deploymentValues(rw http.ResponseWriter, r *http.Request) { if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentValues) { httpapi.Forbidden(rw) @@ -35,3 +35,27 @@ func (api *API) deploymentValues(rw http.ResponseWriter, r *http.Request) { }, ) } + +// @Summary Get deployment stats +// @ID get-deployment-stats +// @Security CoderSessionToken +// @Produce json +// @Tags General +// @Success 200 {object} codersdk.DeploymentStats +// @Router /deployment/stats [get] +func (api *API) deploymentStats(rw http.ResponseWriter, r *http.Request) { + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentStats) { + httpapi.Forbidden(rw) + return + } + + stats, ok := api.metricsCache.DeploymentStats() + if !ok { + httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{ + Message: "Deployment stats are still processing!", + }) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, stats) +} diff --git a/coderd/deploymentconfig_test.go b/coderd/deployment_test.go similarity index 78% rename from coderd/deploymentconfig_test.go rename to coderd/deployment_test.go index a8c2034923430..0b6b1c4806589 100644 --- a/coderd/deploymentconfig_test.go +++ b/coderd/deployment_test.go @@ -38,3 +38,13 @@ func TestDeploymentValues(t *testing.T) { require.Empty(t, scrubbed.Values.PostgresURL.Value()) require.Empty(t, scrubbed.Values.SCIMAPIKey.Value()) } + +func TestDeploymentStats(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, &coderdtest.Options{}) + _ = coderdtest.CreateFirstUser(t, client) + _, err := client.DeploymentStats(ctx) + require.NoError(t, err) +} diff --git a/coderd/metricscache/metricscache.go b/coderd/metricscache/metricscache.go index 176315afc7172..f65d82dd62a02 100644 --- a/coderd/metricscache/metricscache.go +++ b/coderd/metricscache/metricscache.go @@ -3,6 +3,7 @@ package metricscache import ( "context" "database/sql" + "sync" "sync/atomic" "time" @@ -25,34 +26,56 @@ import ( // 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 + database database.Store + log slog.Logger + intervals Intervals deploymentDAUResponses atomic.Pointer[codersdk.DeploymentDAUsResponse] templateDAUResponses atomic.Pointer[map[uuid.UUID]codersdk.TemplateDAUsResponse] templateUniqueUsers atomic.Pointer[map[uuid.UUID]int] templateAverageBuildTime atomic.Pointer[map[uuid.UUID]database.GetTemplateAverageBuildTimeRow] + deploymentStatsResponse atomic.Pointer[codersdk.DeploymentStats] done chan struct{} cancel func() +} - interval time.Duration +type Intervals struct { + TemplateDAUs time.Duration + DeploymentStats time.Duration } -func New(db database.Store, log slog.Logger, interval time.Duration) *Cache { - if interval <= 0 { - interval = time.Hour +func New(db database.Store, log slog.Logger, intervals Intervals) *Cache { + if intervals.TemplateDAUs <= 0 { + intervals.TemplateDAUs = time.Hour + } + if intervals.DeploymentStats <= 0 { + intervals.DeploymentStats = time.Minute } ctx, cancel := context.WithCancel(context.Background()) c := &Cache{ - database: db, - log: log, - done: make(chan struct{}), - cancel: cancel, - interval: interval, + database: db, + intervals: intervals, + log: log, + done: make(chan struct{}), + cancel: cancel, } - go c.run(ctx) + go func() { + var wg sync.WaitGroup + defer close(c.done) + wg.Add(1) + go func() { + defer wg.Done() + c.run(ctx, "template daus", intervals.TemplateDAUs, c.refreshTemplateDAUs) + }() + wg.Add(1) + go func() { + defer wg.Done() + c.run(ctx, "deployment stats", intervals.DeploymentStats, c.refreshDeploymentStats) + }() + wg.Wait() + }() return c } @@ -142,7 +165,7 @@ func countUniqueUsers(rows []database.GetTemplateDAUsRow) int { return len(seen) } -func (c *Cache) refresh(ctx context.Context) error { +func (c *Cache) refreshTemplateDAUs(ctx context.Context) error { //nolint:gocritic // This is a system service. ctx = dbauthz.AsSystemRestricted(ctx) err := c.database.DeleteOldWorkspaceAgentStats(ctx) @@ -199,16 +222,51 @@ func (c *Cache) refresh(ctx context.Context) error { return nil } -func (c *Cache) run(ctx context.Context) { - defer close(c.done) +func (c *Cache) refreshDeploymentStats(ctx context.Context) error { + from := database.Now().Add(-15 * time.Minute) + agentStats, err := c.database.GetDeploymentWorkspaceAgentStats(ctx, from) + if err != nil { + return err + } + workspaceStats, err := c.database.GetDeploymentWorkspaceStats(ctx) + if err != nil { + return err + } + c.deploymentStatsResponse.Store(&codersdk.DeploymentStats{ + AggregatedFrom: from, + CollectedAt: database.Now(), + NextUpdateAt: database.Now().Add(c.intervals.DeploymentStats), + Workspaces: codersdk.WorkspaceDeploymentStats{ + Pending: workspaceStats.PendingWorkspaces, + Building: workspaceStats.BuildingWorkspaces, + Running: workspaceStats.RunningWorkspaces, + Failed: workspaceStats.FailedWorkspaces, + Stopped: workspaceStats.StoppedWorkspaces, + ConnectionLatencyMS: codersdk.WorkspaceConnectionLatencyMS{ + P50: agentStats.WorkspaceConnectionLatency50, + P95: agentStats.WorkspaceConnectionLatency95, + }, + RxBytes: agentStats.WorkspaceRxBytes, + TxBytes: agentStats.WorkspaceTxBytes, + }, + SessionCount: codersdk.SessionCountDeploymentStats{ + VSCode: agentStats.SessionCountVSCode, + SSH: agentStats.SessionCountSSH, + JetBrains: agentStats.SessionCountJetBrains, + ReconnectingPTY: agentStats.SessionCountReconnectingPTY, + }, + }) + return nil +} - ticker := time.NewTicker(c.interval) +func (c *Cache) run(ctx context.Context, name string, interval time.Duration, refresh func(context.Context) error) { + ticker := time.NewTicker(interval) defer ticker.Stop() for { for r := retry.New(time.Millisecond*100, time.Minute); r.Wait(ctx); { start := time.Now() - err := c.refresh(ctx) + err := refresh(ctx) if err != nil { if ctx.Err() != nil { return @@ -218,9 +276,9 @@ func (c *Cache) run(ctx context.Context) { } c.log.Debug( ctx, - "metrics refreshed", + name+" metrics refreshed", slog.F("took", time.Since(start)), - slog.F("interval", c.interval), + slog.F("interval", interval), ) break } @@ -322,3 +380,11 @@ func (c *Cache) TemplateBuildTimeStats(id uuid.UUID) codersdk.TemplateBuildTimeS }, } } + +func (c *Cache) DeploymentStats() (codersdk.DeploymentStats, bool) { + deploymentStats := c.deploymentStatsResponse.Load() + if deploymentStats == nil { + return codersdk.DeploymentStats{}, false + } + return *deploymentStats, true +} diff --git a/coderd/metricscache/metricscache_test.go b/coderd/metricscache/metricscache_test.go index e5ed25e30072a..90d5e93e1d750 100644 --- a/coderd/metricscache/metricscache_test.go +++ b/coderd/metricscache/metricscache_test.go @@ -164,7 +164,9 @@ func TestCache_TemplateUsers(t *testing.T) { t.Parallel() var ( db = dbfake.New() - cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast) + cache = metricscache.New(db, slogtest.Make(t, nil), metricscache.Intervals{ + TemplateDAUs: testutil.IntervalFast, + }) ) defer cache.Close() @@ -286,7 +288,9 @@ func TestCache_BuildTime(t *testing.T) { var ( db = dbfake.New() - cache = metricscache.New(db, slogtest.Make(t, nil), testutil.IntervalFast) + cache = metricscache.New(db, slogtest.Make(t, nil), metricscache.Intervals{ + TemplateDAUs: testutil.IntervalFast, + }) ) defer cache.Close() @@ -370,3 +374,30 @@ func TestCache_BuildTime(t *testing.T) { }) } } + +func TestCache_DeploymentStats(t *testing.T) { + t.Parallel() + db := dbfake.New() + cache := metricscache.New(db, slogtest.Make(t, nil), metricscache.Intervals{ + DeploymentStats: testutil.IntervalFast, + }) + + _, err := db.InsertWorkspaceAgentStat(context.Background(), database.InsertWorkspaceAgentStatParams{ + ID: uuid.New(), + AgentID: uuid.New(), + CreatedAt: database.Now(), + ConnectionCount: 1, + RxBytes: 1, + TxBytes: 1, + SessionCountVSCode: 1, + }) + require.NoError(t, err) + + var stat codersdk.DeploymentStats + require.Eventually(t, func() bool { + var ok bool + stat, ok = cache.DeploymentStats() + return ok + }, testutil.WaitLong, testutil.IntervalMedium) + require.Equal(t, int64(1), stat.SessionCount.VSCode) +} diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index ca8a57245edf5..5e70fc02beaee 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -147,6 +147,10 @@ var ( Type: "deployment_config", } + ResourceDeploymentStats = Object{ + Type: "deployment_stats", + } + ResourceReplicas = Object{ Type: "replicas", } diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 2ec8391593027..9cd2494ce389c 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -980,7 +980,7 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques SessionCountJetBrains: req.SessionCountJetBrains, SessionCountReconnectingPTY: req.SessionCountReconnectingPTY, SessionCountSSH: req.SessionCountSSH, - ConnectionMedianLatencyMS: int64(req.ConnectionMedianLatencyMS), + ConnectionMedianLatencyMS: req.ConnectionMedianLatencyMS, }) if err != nil { httpapi.InternalServerError(rw, err) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 277b88e159b91..5066d9ff11f45 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1158,7 +1158,7 @@ when required by your organization's security policy.`, Flag: "agent-stats-refresh-interval", Env: "AGENT_STATS_REFRESH_INTERVAL", Hidden: true, - Default: (10 * time.Minute).String(), + Default: (30 * time.Second).String(), Value: &c.AgentStatRefreshInterval, }, { @@ -1322,7 +1322,7 @@ func (c *DeploymentValues) WithoutSecrets() (*DeploymentValues, error) { // DeploymentValues returns the deployment config for the coder server. func (c *Client) DeploymentValues(ctx context.Context) (*DeploymentConfig, error) { - res, err := c.Request(ctx, http.MethodGet, "/api/v2/config/deployment", nil) + res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/config", nil) if err != nil { return nil, xerrors.Errorf("execute request: %w", err) } @@ -1340,6 +1340,21 @@ func (c *Client) DeploymentValues(ctx context.Context) (*DeploymentConfig, error return resp, json.NewDecoder(res.Body).Decode(resp) } +func (c *Client) DeploymentStats(ctx context.Context) (DeploymentStats, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/deployment/stats", nil) + if err != nil { + return DeploymentStats{}, xerrors.Errorf("execute request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return DeploymentStats{}, ReadBodyAsError(res) + } + + var df DeploymentStats + return df, json.NewDecoder(res.Body).Decode(&df) +} + type AppearanceConfig struct { LogoURL string `json:"logo_url"` ServiceBanner ServiceBannerConfig `json:"service_banner"` @@ -1511,3 +1526,41 @@ func (c *Client) AppHost(ctx context.Context) (AppHostResponse, error) { var host AppHostResponse return host, json.NewDecoder(res.Body).Decode(&host) } + +type WorkspaceConnectionLatencyMS struct { + P50 float64 + P95 float64 +} + +type WorkspaceDeploymentStats struct { + Pending int64 `json:"pending"` + Building int64 `json:"building"` + Running int64 `json:"running"` + Failed int64 `json:"failed"` + Stopped int64 `json:"stopped"` + + ConnectionLatencyMS WorkspaceConnectionLatencyMS `json:"connection_latency_ms"` + RxBytes int64 `json:"rx_bytes"` + TxBytes int64 `json:"tx_bytes"` +} + +type SessionCountDeploymentStats struct { + VSCode int64 `json:"vscode"` + SSH int64 `json:"ssh"` + JetBrains int64 `json:"jetbrains"` + ReconnectingPTY int64 `json:"reconnecting_pty"` +} + +type DeploymentStats struct { + // AggregatedFrom is the time in which stats are aggregated from. + // This might be back in time a specific duration or interval. + AggregatedFrom time.Time `json:"aggregated_from" format:"date-time"` + // CollectedAt is the time in which stats are collected at. + CollectedAt time.Time `json:"collected_at" format:"date-time"` + // NextUpdateAt is the time when the next batch of stats will + // be updated. + NextUpdateAt time.Time `json:"next_update_at" format:"date-time"` + + Workspaces WorkspaceDeploymentStats `json:"workspaces"` + SessionCount SessionCountDeploymentStats `json:"session_count"` +} diff --git a/docs/api/general.md b/docs/api/general.md index 649c051ea87dd..2516d79e995c2 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -64,18 +64,53 @@ curl -X GET http://coder-server:8080/api/v2/buildinfo \ | ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------ | | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.BuildInfoResponse](schemas.md#codersdkbuildinforesponse) | +## Report CSP violations + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/csp/reports \ + -H 'Content-Type: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /csp/reports` + +> Body parameter + +```json +{ + "csp-report": {} +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ---------------------------------------------------- | -------- | ---------------- | +| `body` | body | [coderd.cspViolation](schemas.md#coderdcspviolation) | true | Violation report | + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------ | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get deployment config ### Code samples ```shell # Example request using curl -curl -X GET http://coder-server:8080/api/v2/config/deployment \ +curl -X GET http://coder-server:8080/api/v2/deployment/config \ -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`GET /config/deployment` +`GET /deployment/config` ### Example responses @@ -362,38 +397,55 @@ curl -X GET http://coder-server:8080/api/v2/config/deployment \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Report CSP violations +## Get deployment stats ### Code samples ```shell # Example request using curl -curl -X POST http://coder-server:8080/api/v2/csp/reports \ - -H 'Content-Type: application/json' \ +curl -X GET http://coder-server:8080/api/v2/deployment/stats \ + -H 'Accept: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` -`POST /csp/reports` +`GET /deployment/stats` -> Body parameter +### Example responses + +> 200 Response ```json { - "csp-report": {} + "aggregated_from": "2019-08-24T14:15:22Z", + "collected_at": "2019-08-24T14:15:22Z", + "next_update_at": "2019-08-24T14:15:22Z", + "session_count": { + "jetbrains": 0, + "reconnecting_pty": 0, + "ssh": 0, + "vscode": 0 + }, + "workspaces": { + "building": 0, + "connection_latency_ms": { + "p50": 0, + "p95": 0 + }, + "failed": 0, + "pending": 0, + "running": 0, + "rx_bytes": 0, + "stopped": 0, + "tx_bytes": 0 + } } ``` -### Parameters - -| Name | In | Type | Required | Description | -| ------ | ---- | ---------------------------------------------------- | -------- | ---------------- | -| `body` | body | [coderd.cspViolation](schemas.md#coderdcspviolation) | true | Violation report | - ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DeploymentStats](schemas.md#codersdkdeploymentstats) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/api/schemas.md b/docs/api/schemas.md index f96c052f286c0..bfe21043b84b9 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1947,6 +1947,45 @@ CreateParameterRequest is a structure used to create a new parameter value for a | --------- | ----------------------------------------------- | -------- | ------------ | ----------- | | `entries` | array of [codersdk.DAUEntry](#codersdkdauentry) | false | | | +## codersdk.DeploymentStats + +```json +{ + "aggregated_from": "2019-08-24T14:15:22Z", + "collected_at": "2019-08-24T14:15:22Z", + "next_update_at": "2019-08-24T14:15:22Z", + "session_count": { + "jetbrains": 0, + "reconnecting_pty": 0, + "ssh": 0, + "vscode": 0 + }, + "workspaces": { + "building": 0, + "connection_latency_ms": { + "p50": 0, + "p95": 0 + }, + "failed": 0, + "pending": 0, + "running": 0, + "rx_bytes": 0, + "stopped": 0, + "tx_bytes": 0 + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------- | ---------------------------------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------- | +| `aggregated_from` | string | false | | Aggregated from is the time in which stats are aggregated from. This might be back in time a specific duration or interval. | +| `collected_at` | string | false | | Collected at is the time in which stats are collected at. | +| `next_update_at` | string | false | | Next update at is the time when the next batch of stats will be updated. | +| `session_count` | [codersdk.SessionCountDeploymentStats](#codersdksessioncountdeploymentstats) | false | | | +| `workspaces` | [codersdk.WorkspaceDeploymentStats](#codersdkworkspacedeploymentstats) | false | | | + ## codersdk.DeploymentValues ```json @@ -3293,6 +3332,26 @@ Parameter represents a set value for the scope. | `enabled` | boolean | false | | | | `message` | string | false | | | +## codersdk.SessionCountDeploymentStats + +```json +{ + "jetbrains": 0, + "reconnecting_pty": 0, + "ssh": 0, + "vscode": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------- | -------- | ------------ | ----------- | +| `jetbrains` | integer | false | | | +| `reconnecting_pty` | integer | false | | | +| `ssh` | integer | false | | | +| `vscode` | integer | false | | | + ## codersdk.SupportConfig ```json @@ -4746,6 +4805,53 @@ Parameter represents a set value for the scope. | `name` | string | false | | | | `value` | string | false | | | +## codersdk.WorkspaceConnectionLatencyMS + +```json +{ + "p50": 0, + "p95": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----- | ------ | -------- | ------------ | ----------- | +| `p50` | number | false | | | +| `p95` | number | false | | | + +## codersdk.WorkspaceDeploymentStats + +```json +{ + "building": 0, + "connection_latency_ms": { + "p50": 0, + "p95": 0 + }, + "failed": 0, + "pending": 0, + "running": 0, + "rx_bytes": 0, + "stopped": 0, + "tx_bytes": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------------- | ------------------------------------------------------------------------------ | -------- | ------------ | ----------- | +| `building` | integer | false | | | +| `connection_latency_ms` | [codersdk.WorkspaceConnectionLatencyMS](#codersdkworkspaceconnectionlatencyms) | false | | | +| `failed` | integer | false | | | +| `pending` | integer | false | | | +| `running` | integer | false | | | +| `rx_bytes` | integer | false | | | +| `stopped` | integer | false | | | +| `tx_bytes` | integer | false | | | + ## codersdk.WorkspaceQuota ```json diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 76c0cefc32676..3225311c31d20 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -807,10 +807,16 @@ export const getAgentListeningPorts = async ( } export const getDeploymentValues = async (): Promise => { - const response = await axios.get(`/api/v2/config/deployment`) + const response = await axios.get(`/api/v2/deployment/config`) return response.data } +export const getDeploymentStats = + async (): Promise => { + const response = await axios.get(`/api/v2/deployment/stats`) + return response.data + } + export const getReplicas = async (): Promise => { const response = await axios.get(`/api/v2/replicas`) return response.data diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index adc24203fe560..cc7faba89cf34 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -312,6 +312,15 @@ export interface DeploymentDAUsResponse { readonly entries: DAUEntry[] } +// From codersdk/deployment.go +export interface DeploymentStats { + readonly aggregated_from: string + readonly collected_at: string + readonly next_update_at: string + readonly workspaces: WorkspaceDeploymentStats + readonly session_count: SessionCountDeploymentStats +} + // From codersdk/deployment.go export interface DeploymentValues { // This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.Bool") @@ -734,6 +743,14 @@ export interface ServiceBannerConfig { readonly background_color?: string } +// From codersdk/deployment.go +export interface SessionCountDeploymentStats { + readonly vscode: number + readonly ssh: number + readonly jetbrains: number + readonly reconnecting_pty: number +} + // From codersdk/deployment.go export interface SupportConfig { // Named type "github.com/coder/coder/cli/clibase.Struct[[]github.com/coder/coder/codersdk.LinkConfig]" unknown, using "any" @@ -1144,6 +1161,24 @@ export interface WorkspaceBuildsRequest extends Pagination { readonly Since: string } +// From codersdk/deployment.go +export interface WorkspaceConnectionLatencyMS { + readonly P50: number + readonly P95: number +} + +// From codersdk/deployment.go +export interface WorkspaceDeploymentStats { + readonly pending: number + readonly building: number + readonly running: number + readonly failed: number + readonly stopped: number + readonly connection_latency_ms: WorkspaceConnectionLatencyMS + readonly rx_bytes: number + readonly tx_bytes: number +} + // From codersdk/workspaces.go export interface WorkspaceFilter { readonly q?: string diff --git a/site/src/components/Dashboard/DashboardLayout.tsx b/site/src/components/Dashboard/DashboardLayout.tsx index 1dfd2a15c0e4c..843cf77e041f5 100644 --- a/site/src/components/Dashboard/DashboardLayout.tsx +++ b/site/src/components/Dashboard/DashboardLayout.tsx @@ -1,18 +1,19 @@ import { makeStyles } from "@material-ui/core/styles" import { useMachine } from "@xstate/react" +import { UpdateCheckResponse } from "api/typesGenerated" +import { DeploymentBanner } from "components/DeploymentBanner/DeploymentBanner" +import { LicenseBanner } from "components/LicenseBanner/LicenseBanner" import { Loader } from "components/Loader/Loader" -import { FC, Suspense } from "react" -import { Navbar } from "../Navbar/Navbar" -import { UpdateCheckBanner } from "components/UpdateCheckBanner/UpdateCheckBanner" import { Margins } from "components/Margins/Margins" -import { Outlet } from "react-router-dom" -import { LicenseBanner } from "components/LicenseBanner/LicenseBanner" import { ServiceBanner } from "components/ServiceBanner/ServiceBanner" -import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService" +import { UpdateCheckBanner } from "components/UpdateCheckBanner/UpdateCheckBanner" import { usePermissions } from "hooks/usePermissions" -import { UpdateCheckResponse } from "api/typesGenerated" -import { DashboardProvider } from "./DashboardProvider" +import { FC, Suspense } from "react" +import { Outlet } from "react-router-dom" import { dashboardContentBottomPadding } from "theme/constants" +import { updateCheckMachine } from "xServices/updateCheck/updateCheckXService" +import { Navbar } from "../Navbar/Navbar" +import { DashboardProvider } from "./DashboardProvider" export const DashboardLayout: FC = () => { const styles = useStyles() @@ -51,6 +52,8 @@ export const DashboardLayout: FC = () => { + + ) diff --git a/site/src/components/DeploymentBanner/DeploymentBanner.tsx b/site/src/components/DeploymentBanner/DeploymentBanner.tsx new file mode 100644 index 0000000000000..d5cd1e25e5390 --- /dev/null +++ b/site/src/components/DeploymentBanner/DeploymentBanner.tsx @@ -0,0 +1,20 @@ +import { useMachine } from "@xstate/react" +import { usePermissions } from "hooks/usePermissions" +import { DeploymentBannerView } from "./DeploymentBannerView" +import { deploymentStatsMachine } from "../../xServices/deploymentStats/deploymentStatsMachine" + +export const DeploymentBanner: React.FC = () => { + const permissions = usePermissions() + const [state, sendEvent] = useMachine(deploymentStatsMachine) + + if (!permissions.viewDeploymentValues || !state.context.deploymentStats) { + return null + } + + return ( + sendEvent("RELOAD")} + /> + ) +} diff --git a/site/src/components/DeploymentBanner/DeploymentBannerView.stories.tsx b/site/src/components/DeploymentBanner/DeploymentBannerView.stories.tsx new file mode 100644 index 0000000000000..dd54d531a589b --- /dev/null +++ b/site/src/components/DeploymentBanner/DeploymentBannerView.stories.tsx @@ -0,0 +1,20 @@ +import { Story } from "@storybook/react" +import { MockDeploymentStats } from "testHelpers/entities" +import { + DeploymentBannerView, + DeploymentBannerViewProps, +} from "./DeploymentBannerView" + +export default { + title: "components/DeploymentBannerView", + component: DeploymentBannerView, +} + +const Template: Story = (args) => ( + +) + +export const Preview = Template.bind({}) +Preview.args = { + stats: MockDeploymentStats, +} diff --git a/site/src/components/DeploymentBanner/DeploymentBannerView.tsx b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx new file mode 100644 index 0000000000000..a5c80b5d58f2f --- /dev/null +++ b/site/src/components/DeploymentBanner/DeploymentBannerView.tsx @@ -0,0 +1,342 @@ +import { DeploymentStats, WorkspaceStatus } from "api/typesGenerated" +import { FC, useMemo, useEffect, useState } from "react" +import prettyBytes from "pretty-bytes" +import { getStatus } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge" +import BuildingIcon from "@material-ui/icons/Build" +import { makeStyles } from "@material-ui/core/styles" +import { RocketIcon } from "components/Icons/RocketIcon" +import { MONOSPACE_FONT_FAMILY } from "theme/constants" +import Tooltip from "@material-ui/core/Tooltip" +import { Link as RouterLink } from "react-router-dom" +import Link from "@material-ui/core/Link" +import InfoIcon from "@material-ui/icons/InfoOutlined" +import { VSCodeIcon } from "components/Icons/VSCodeIcon" +import DownloadIcon from "@material-ui/icons/CloudDownload" +import UploadIcon from "@material-ui/icons/CloudUpload" +import LatencyIcon from "@material-ui/icons/SettingsEthernet" +import WebTerminalIcon from "@material-ui/icons/WebAsset" +import { TerminalIcon } from "components/Icons/TerminalIcon" +import dayjs from "dayjs" +import CollectedIcon from "@material-ui/icons/Compare" +import RefreshIcon from "@material-ui/icons/Refresh" +import Button from "@material-ui/core/Button" + +export const bannerHeight = 36 + +export interface DeploymentBannerViewProps { + fetchStats?: () => void + stats?: DeploymentStats +} + +export const DeploymentBannerView: FC = ({ + stats, + fetchStats, +}) => { + const styles = useStyles() + const aggregatedMinutes = useMemo(() => { + if (!stats) { + return + } + return dayjs(stats.collected_at).diff(stats.aggregated_from, "minutes") + }, [stats]) + const displayLatency = stats?.workspaces.connection_latency_ms.P50 || -1 + const [timeUntilRefresh, setTimeUntilRefresh] = useState(0) + useEffect(() => { + if (!stats || !fetchStats) { + return + } + + let timeUntilRefresh = dayjs(stats.next_update_at).diff( + stats.collected_at, + "seconds", + ) + setTimeUntilRefresh(timeUntilRefresh) + let canceled = false + const loop = () => { + if (canceled) { + return + } + setTimeUntilRefresh(timeUntilRefresh--) + if (timeUntilRefresh > 0) { + return setTimeout(loop, 1000) + } + fetchStats() + } + const timeout = setTimeout(loop, 1000) + return () => { + canceled = true + clearTimeout(timeout) + } + }, [fetchStats, stats]) + const lastAggregated = useMemo(() => { + if (!stats) { + return + } + if (!fetchStats) { + // Storybook! + return "just now" + } + return dayjs().to(dayjs(stats.collected_at)) + // eslint-disable-next-line react-hooks/exhaustive-deps -- We want this to periodically update! + }, [timeUntilRefresh, stats]) + + return ( +
+ +
+ +
+
+
+
Workspaces
+
+ + + + + + + + + +
+
+
+ +
+ Transmission + +
+
+ +
+ +
+ + {stats ? prettyBytes(stats.workspaces.rx_bytes) : "-"} +
+
+ + +
+ + {stats ? prettyBytes(stats.workspaces.tx_bytes) : "-"} +
+
+ + +
+ + {displayLatency > 0 ? displayLatency?.toFixed(2) + " ms" : "-"} +
+
+
+
+
+
Active Connections
+ +
+ +
+ + {typeof stats?.session_count.vscode === "undefined" + ? "-" + : stats?.session_count.vscode} +
+
+ + +
+ + {typeof stats?.session_count.ssh === "undefined" + ? "-" + : stats?.session_count.ssh} +
+
+ + +
+ + {typeof stats?.session_count.reconnecting_pty === "undefined" + ? "-" + : stats?.session_count.reconnecting_pty} +
+
+
+
+
+ +
+ + {lastAggregated} +
+
+ + + + +
+
+ ) +} + +const ValueSeparator: FC = () => { + const styles = useStyles() + return
/
+} + +const WorkspaceBuildValue: FC<{ + status: WorkspaceStatus + count?: number +}> = ({ status, count }) => { + const styles = useStyles() + const displayStatus = getStatus(status) + let statusText = displayStatus.text + let icon = displayStatus.icon + if (status === "starting") { + icon = + statusText = "Building" + } + + return ( + + +
+ {icon} + {typeof count === "undefined" ? "-" : count} +
+ +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + rocket: { + display: "flex", + alignItems: "center", + + "& svg": { + width: 16, + height: 16, + }, + + [theme.breakpoints.down("md")]: { + display: "none", + }, + }, + container: { + position: "sticky", + height: bannerHeight, + bottom: 0, + zIndex: 1, + padding: theme.spacing(1, 2), + backgroundColor: theme.palette.background.paper, + display: "flex", + alignItems: "center", + fontFamily: MONOSPACE_FONT_FAMILY, + fontSize: 12, + gap: theme.spacing(4), + borderTop: `1px solid ${theme.palette.divider}`, + + [theme.breakpoints.down("md")]: { + flexDirection: "column", + gap: theme.spacing(1), + alignItems: "left", + }, + }, + group: { + display: "flex", + alignItems: "center", + }, + category: { + marginRight: theme.spacing(2), + color: theme.palette.text.hint, + + "& svg": { + width: 12, + height: 12, + marginBottom: 2, + }, + }, + values: { + display: "flex", + gap: theme.spacing(1), + color: theme.palette.text.secondary, + }, + valueSeparator: { + color: theme.palette.text.disabled, + }, + value: { + display: "flex", + alignItems: "center", + gap: theme.spacing(0.5), + + "& svg": { + width: 12, + height: 12, + }, + }, + iconStripColor: { + "& *": { + fill: "currentColor", + }, + }, + refresh: { + color: theme.palette.text.hint, + marginLeft: "auto", + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + }, + refreshButton: { + margin: 0, + padding: "0px 8px", + height: "unset", + minHeight: "unset", + fontSize: "unset", + color: "unset", + border: 0, + minWidth: "unset", + fontFamily: "inherit", + + "& svg": { + marginRight: theme.spacing(0.5), + }, + }, +})) diff --git a/site/src/components/Icons/RocketIcon.tsx b/site/src/components/Icons/RocketIcon.tsx new file mode 100644 index 0000000000000..0973866ea76a1 --- /dev/null +++ b/site/src/components/Icons/RocketIcon.tsx @@ -0,0 +1,7 @@ +import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon" + +export const RocketIcon: typeof SvgIcon = (props: SvgIconProps) => ( + + + +) diff --git a/site/src/components/Icons/TerminalIcon.tsx b/site/src/components/Icons/TerminalIcon.tsx new file mode 100644 index 0000000000000..24a0b98a5fa37 --- /dev/null +++ b/site/src/components/Icons/TerminalIcon.tsx @@ -0,0 +1,7 @@ +import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon" + +export const TerminalIcon: typeof SvgIcon = (props: SvgIconProps) => ( + + + +) diff --git a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx index 6cb8482a46606..73689d69a1e55 100644 --- a/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx +++ b/site/src/components/TemplateVersionEditor/TemplateVersionEditor.tsx @@ -13,6 +13,7 @@ import { } from "api/typesGenerated" import { Avatar } from "components/Avatar/Avatar" import { AvatarData } from "components/AvatarData/AvatarData" +import { bannerHeight } from "components/DeploymentBanner/DeploymentBannerView" import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" import { FC, useCallback, useEffect, useRef, useState } from "react" @@ -46,6 +47,7 @@ export interface TemplateVersionEditorProps { defaultFileTree: FileTree buildLogs?: ProvisionerJobLog[] resources?: WorkspaceResource[] + deploymentBannerVisible?: boolean disablePreview: boolean disableUpdate: boolean onPreview: (files: FileTree) => void @@ -70,6 +72,7 @@ export const TemplateVersionEditor: FC = ({ disablePreview, disableUpdate, template, + deploymentBannerVisible, templateVersion, defaultFileTree, onPreview, @@ -148,6 +151,7 @@ export const TemplateVersionEditor: FC = ({ const styles = useStyles({ templateVersionSucceeded, showBuildLogs, + deploymentBannerVisible, }) return ( @@ -383,10 +387,14 @@ const useStyles = makeStyles< { templateVersionSucceeded: boolean showBuildLogs: boolean + deploymentBannerVisible: boolean } >((theme) => ({ root: { - height: `calc(100vh - ${navHeight}px)`, + height: (props) => + `calc(100vh - ${ + navHeight + (props.deploymentBannerVisible ? bannerHeight : 0) + }px)`, background: theme.palette.background.default, flex: 1, display: "flex", diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 1df7f9a9a340c..0a23ac7409bfa 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -1,7 +1,8 @@ import CircularProgress from "@material-ui/core/CircularProgress" import ErrorIcon from "@material-ui/icons/ErrorOutline" -import StopIcon from "@material-ui/icons/PauseOutlined" +import StopIcon from "@material-ui/icons/StopOutlined" import PlayIcon from "@material-ui/icons/PlayArrowOutlined" +import QueuedIcon from "@material-ui/icons/HourglassEmpty" import { WorkspaceBuild } from "api/typesGenerated" import { Pill } from "components/Pill/Pill" import i18next from "i18next" @@ -13,7 +14,7 @@ const LoadingIcon: FC = () => { } export const getStatus = ( - build: WorkspaceBuild, + buildStatus: WorkspaceBuild["status"], ): { type?: PaletteIndex text: string @@ -21,7 +22,7 @@ export const getStatus = ( } => { const { t } = i18next - switch (build.status) { + switch (buildStatus) { case undefined: return { text: t("workspaceStatus.loading", { ns: "common" }), @@ -85,7 +86,7 @@ export const getStatus = ( return { type: "info", text: t("workspaceStatus.pending", { ns: "common" }), - icon: , + icon: , } } } @@ -98,6 +99,6 @@ export type WorkspaceStatusBadgeProps = { export const WorkspaceStatusBadge: FC< PropsWithChildren > = ({ build, className }) => { - const { text, icon, type } = getStatus(build) + const { text, icon, type } = getStatus(build.status) return } diff --git a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index c6702a3ebf2b2..d5dcfd71abfa3 100644 --- a/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionPage/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -1,6 +1,7 @@ import { useMachine } from "@xstate/react" import { TemplateVersionEditor } from "components/TemplateVersionEditor/TemplateVersionEditor" import { useOrganizationId } from "hooks/useOrganizationId" +import { usePermissions } from "hooks/usePermissions" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useParams } from "react-router-dom" @@ -19,6 +20,7 @@ export const TemplateVersionEditorPage: FC = () => { const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, { context: { orgId }, }) + const permissions = usePermissions() const { isSuccess, data } = useTemplateVersionData( { orgId, @@ -41,6 +43,7 @@ export const TemplateVersionEditorPage: FC = () => { {isSuccess && ( { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 84a2099a33036..01a2ab60cc090 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1445,6 +1445,7 @@ export const MockPermissions: Permissions = { viewAuditLog: true, viewDeploymentValues: true, viewUpdateCheck: true, + viewDeploymentStats: true, } export const MockAppearance: TypesGen.AppearanceConfig = { @@ -1536,3 +1537,28 @@ export const MockTemplateVersionGitAuth: TypesGen.TemplateVersionGitAuth = { authenticate_url: "https://example.com/gitauth/github", authenticated: false, } + +export const MockDeploymentStats: TypesGen.DeploymentStats = { + aggregated_from: "2023-03-06T19:08:55.211625Z", + collected_at: "2023-03-06T19:12:55.211625Z", + next_update_at: "2023-03-06T19:20:55.211625Z", + session_count: { + vscode: 128, + jetbrains: 5, + ssh: 32, + reconnecting_pty: 15, + }, + workspaces: { + building: 15, + failed: 12, + pending: 5, + running: 32, + stopped: 16, + connection_latency_ms: { + P50: 32.56, + P95: 15.23, + }, + rx_bytes: 15613513253, + tx_bytes: 36113513253, + }, +} diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 9094d71928a42..44030790830e6 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -17,6 +17,7 @@ export const checks = { viewDeploymentValues: "viewDeploymentValues", createGroup: "createGroup", viewUpdateCheck: "viewUpdateCheck", + viewDeploymentStats: "viewDeploymentStats", } as const export const permissionsToCheck = { @@ -74,6 +75,12 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.viewDeploymentStats]: { + object: { + resource_type: "deployment_stats", + }, + action: "read", + }, } as const export type Permissions = Record diff --git a/site/src/xServices/deploymentStats/deploymentStatsMachine.ts b/site/src/xServices/deploymentStats/deploymentStatsMachine.ts new file mode 100644 index 0000000000000..a68447b54b45a --- /dev/null +++ b/site/src/xServices/deploymentStats/deploymentStatsMachine.ts @@ -0,0 +1,59 @@ +import { getDeploymentStats } from "api/api" +import { DeploymentStats } from "api/typesGenerated" +import { assign, createMachine } from "xstate" + +export const deploymentStatsMachine = createMachine( + { + id: "deploymentStatsMachine", + predictableActionArguments: true, + + schema: { + context: {} as { + deploymentStats?: DeploymentStats + getDeploymentStatsError?: unknown + }, + events: {} as { type: "RELOAD" }, + services: {} as { + getDeploymentStats: { + data: DeploymentStats + } + }, + }, + tsTypes: {} as import("./deploymentStatsMachine.typegen").Typegen0, + initial: "stats", + states: { + stats: { + invoke: { + src: "getDeploymentStats", + onDone: { + target: "idle", + actions: ["assignDeploymentStats"], + }, + onError: { + target: "idle", + actions: ["assignDeploymentStatsError"], + }, + }, + tags: "loading", + }, + idle: { + on: { + RELOAD: "stats", + }, + }, + }, + }, + { + services: { + getDeploymentStats: getDeploymentStats, + }, + actions: { + assignDeploymentStats: assign({ + deploymentStats: (_, { data }) => data, + }), + assignDeploymentStatsError: assign({ + getDeploymentStatsError: (_, { data }) => data, + }), + }, + }, +)