feat: add connection statistics for workspace agents (#6469)

* fix: don't make session counts cumulative

This made for some weird tracking... we want the point-in-time
number of counts!

* Add databasefake query for getting agent stats

* Add deployment stats endpoint

* The query... works?!?

* Fix aggregation query

* Select from multiple tables instead

* Fix continuous stats

* Increase period of stat refreshes

* Add workspace counts to deployment stats

* fmt

* Add a slight bit of responsiveness

* Fix template version editor overflow

* Add refresh button

* Fix font family on button

* Fix latest stat being reported

* Revert agent conn stats

* Fix linting error

* Fix tests

* Fix gen

* Fix migrations

* Block on sending stat updates

* Add test fixtures

* Fix response structure

* make gen
This commit is contained in:
Kyle Carberry 2023-03-08 21:05:45 -06:00 committed by GitHub
parent 9d40d2ffdc
commit 5304b4e483
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1790 additions and 174 deletions

View File

@ -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:
}
}

View File

@ -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) {

158
coderd/apidoc/docs.go generated
View File

@ -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": {

View File

@ -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": {

View File

@ -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,

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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

View File

@ -0,0 +1 @@
ALTER TABLE workspace_agent_stats ALTER COLUMN connection_median_latency_ms TYPE bigint;

View File

@ -0,0 +1 @@
ALTER TABLE workspace_agent_stats ALTER COLUMN connection_median_latency_ms TYPE FLOAT;

View File

@ -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
);

View File

@ -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
);

View File

@ -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"`

View File

@ -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.

View File

@ -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)
})
}

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}
func New(db database.Store, log slog.Logger, interval time.Duration) *Cache {
if interval <= 0 {
interval = time.Hour
type Intervals struct {
TemplateDAUs time.Duration
DeploymentStats time.Duration
}
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
}

View File

@ -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)
}

View File

@ -147,6 +147,10 @@ var (
Type: "deployment_config",
}
ResourceDeploymentStats = Object{
Type: "deployment_stats",
}
ResourceReplicas = Object{
Type: "replicas",
}

View File

@ -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)

View File

@ -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"`
}

View File

@ -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).

View File

@ -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

View File

@ -807,10 +807,16 @@ export const getAgentListeningPorts = async (
}
export const getDeploymentValues = async (): Promise<DeploymentConfig> => {
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<TypesGen.DeploymentStats> => {
const response = await axios.get(`/api/v2/deployment/stats`)
return response.data
}
export const getReplicas = async (): Promise<TypesGen.Replica[]> => {
const response = await axios.get(`/api/v2/replicas`)
return response.data

View File

@ -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

View File

@ -1,18 +1,19 @@
import { makeStyles } from "@material-ui/core/styles"
import { useMachine } from "@xstate/react"
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 { usePermissions } from "hooks/usePermissions"
import { UpdateCheckResponse } from "api/typesGenerated"
import { DashboardProvider } from "./DashboardProvider"
import { DeploymentBanner } from "components/DeploymentBanner/DeploymentBanner"
import { LicenseBanner } from "components/LicenseBanner/LicenseBanner"
import { Loader } from "components/Loader/Loader"
import { Margins } from "components/Margins/Margins"
import { ServiceBanner } from "components/ServiceBanner/ServiceBanner"
import { UpdateCheckBanner } from "components/UpdateCheckBanner/UpdateCheckBanner"
import { usePermissions } from "hooks/usePermissions"
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 = () => {
<Outlet />
</Suspense>
</div>
<DeploymentBanner />
</div>
</DashboardProvider>
)

View File

@ -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 (
<DeploymentBannerView
stats={state.context.deploymentStats}
fetchStats={() => sendEvent("RELOAD")}
/>
)
}

View File

@ -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<DeploymentBannerViewProps> = (args) => (
<DeploymentBannerView {...args} />
)
export const Preview = Template.bind({})
Preview.args = {
stats: MockDeploymentStats,
}

View File

@ -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<DeploymentBannerViewProps> = ({
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 (
<div className={styles.container}>
<Tooltip title="Status of your Coder deployment. Only visible for admins!">
<div className={styles.rocket}>
<RocketIcon />
</div>
</Tooltip>
<div className={styles.group}>
<div className={styles.category}>Workspaces</div>
<div className={styles.values}>
<WorkspaceBuildValue
status="pending"
count={stats?.workspaces.pending}
/>
<ValueSeparator />
<WorkspaceBuildValue
status="starting"
count={stats?.workspaces.building}
/>
<ValueSeparator />
<WorkspaceBuildValue
status="running"
count={stats?.workspaces.running}
/>
<ValueSeparator />
<WorkspaceBuildValue
status="stopped"
count={stats?.workspaces.stopped}
/>
<ValueSeparator />
<WorkspaceBuildValue
status="failed"
count={stats?.workspaces.failed}
/>
</div>
</div>
<div className={styles.group}>
<Tooltip title={`Activity in the last ~${aggregatedMinutes} minutes`}>
<div className={styles.category}>
Transmission
<InfoIcon />
</div>
</Tooltip>
<div className={styles.values}>
<Tooltip title="Data sent through workspace workspaces">
<div className={styles.value}>
<DownloadIcon />
{stats ? prettyBytes(stats.workspaces.rx_bytes) : "-"}
</div>
</Tooltip>
<ValueSeparator />
<Tooltip title="Data sent from workspace connections">
<div className={styles.value}>
<UploadIcon />
{stats ? prettyBytes(stats.workspaces.tx_bytes) : "-"}
</div>
</Tooltip>
<ValueSeparator />
<Tooltip
title={
displayLatency < 0
? "No recent workspace connections have been made"
: "The average latency of user connections to workspaces"
}
>
<div className={styles.value}>
<LatencyIcon />
{displayLatency > 0 ? displayLatency?.toFixed(2) + " ms" : "-"}
</div>
</Tooltip>
</div>
</div>
<div className={styles.group}>
<div className={styles.category}>Active Connections</div>
<div className={styles.values}>
<Tooltip title="VS Code Editors with the Coder Remote Extension">
<div className={styles.value}>
<VSCodeIcon className={styles.iconStripColor} />
{typeof stats?.session_count.vscode === "undefined"
? "-"
: stats?.session_count.vscode}
</div>
</Tooltip>
<ValueSeparator />
<Tooltip title="SSH Sessions">
<div className={styles.value}>
<TerminalIcon />
{typeof stats?.session_count.ssh === "undefined"
? "-"
: stats?.session_count.ssh}
</div>
</Tooltip>
<ValueSeparator />
<Tooltip title="Web Terminal Sessions">
<div className={styles.value}>
<WebTerminalIcon />
{typeof stats?.session_count.reconnecting_pty === "undefined"
? "-"
: stats?.session_count.reconnecting_pty}
</div>
</Tooltip>
</div>
</div>
<div className={styles.refresh}>
<Tooltip title="The last time stats were aggregated. Workspaces report statistics periodically, so it may take a bit for these to update!">
<div className={styles.value}>
<CollectedIcon />
{lastAggregated}
</div>
</Tooltip>
<Tooltip title="A countdown until stats are fetched again. Click to refresh!">
<Button
className={`${styles.value} ${styles.refreshButton}`}
title="Refresh"
onClick={() => {
if (fetchStats) {
fetchStats()
}
}}
variant="text"
>
<RefreshIcon />
{timeUntilRefresh}s
</Button>
</Tooltip>
</div>
</div>
)
}
const ValueSeparator: FC = () => {
const styles = useStyles()
return <div className={styles.valueSeparator}>/</div>
}
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 = <BuildingIcon />
statusText = "Building"
}
return (
<Tooltip title={`${statusText} Workspaces`}>
<Link
component={RouterLink}
to={`/workspaces?filter=${encodeURIComponent("status:" + status)}`}
>
<div className={styles.value}>
{icon}
{typeof count === "undefined" ? "-" : count}
</div>
</Link>
</Tooltip>
)
}
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),
},
},
}))

View File

@ -0,0 +1,7 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
export const RocketIcon: typeof SvgIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 24 24">
<path d="M12 2.5s4.5 2.04 4.5 10.5c0 2.49-1.04 5.57-1.6 7H9.1c-.56-1.43-1.6-4.51-1.6-7C7.5 4.54 12 2.5 12 2.5zm2 8.5c0-1.1-.9-2-2-2s-2 .9-2 2 .9 2 2 2 2-.9 2-2zm-6.31 9.52c-.48-1.23-1.52-4.17-1.67-6.87l-1.13.75c-.56.38-.89 1-.89 1.67V22l3.69-1.48zM20 22v-5.93c0-.67-.33-1.29-.89-1.66l-1.13-.75c-.15 2.69-1.2 5.64-1.67 6.87L20 22z" />
</SvgIcon>
)

View File

@ -0,0 +1,7 @@
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
export const TerminalIcon: typeof SvgIcon = (props: SvgIconProps) => (
<SvgIcon {...props} viewBox="0 0 24 24">
<path d="M20 4H4c-1.11 0-2 .9-2 2v12c0 1.1.89 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.89-2-2-2zm0 14H4V8h16v10zm-2-1h-6v-2h6v2zM7.5 17l-1.41-1.41L8.67 13l-2.59-2.59L7.5 9l4 4-4 4z" />
</SvgIcon>
)

View File

@ -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<TemplateVersionEditorProps> = ({
disablePreview,
disableUpdate,
template,
deploymentBannerVisible,
templateVersion,
defaultFileTree,
onPreview,
@ -148,6 +151,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
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",

View File

@ -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: <LoadingIcon />,
icon: <QueuedIcon />,
}
}
}
@ -98,6 +99,6 @@ export type WorkspaceStatusBadgeProps = {
export const WorkspaceStatusBadge: FC<
PropsWithChildren<WorkspaceStatusBadgeProps>
> = ({ build, className }) => {
const { text, icon, type } = getStatus(build)
const { text, icon, type } = getStatus(build.status)
return <Pill className={className} icon={icon} text={text} type={type} />
}

View File

@ -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 && (
<TemplateVersionEditor
template={data.template}
deploymentBannerVisible={permissions.viewDeploymentStats}
templateVersion={editorState.context.version || data.version}
defaultFileTree={data.fileTree}
onPreview={(fileTree) => {

View File

@ -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,
},
}

View File

@ -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<keyof typeof permissionsToCheck, boolean>

View File

@ -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,
}),
},
},
)