mirror of https://github.com/coder/coder.git
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:
parent
9d40d2ffdc
commit
5304b4e483
|
@ -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:
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE workspace_agent_stats ALTER COLUMN connection_median_latency_ms TYPE bigint;
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE workspace_agent_stats ALTER COLUMN connection_median_latency_ms TYPE FLOAT;
|
|
@ -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
|
||||
);
|
|
@ -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
|
||||
);
|
|
@ -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"`
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -147,6 +147,10 @@ var (
|
|||
Type: "deployment_config",
|
||||
}
|
||||
|
||||
ResourceDeploymentStats = Object{
|
||||
Type: "deployment_stats",
|
||||
}
|
||||
|
||||
ResourceReplicas = Object{
|
||||
Type: "replicas",
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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).
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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")}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
}))
|
|
@ -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>
|
||||
)
|
|
@ -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>
|
||||
)
|
|
@ -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",
|
||||
|
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
Loading…
Reference in New Issue