mirror of https://github.com/coder/coder.git
feat(coderd): add user latency and template insights endpoints (#8519)
Part of #8514 Refs #8109
This commit is contained in:
parent
539fcf9e6b
commit
30fe153296
|
@ -878,6 +878,56 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/insights/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Insights"
|
||||
],
|
||||
"summary": "Get insights about templates",
|
||||
"operationId": "get-insights-about-templates",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TemplateInsightsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/insights/user-latency": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Insights"
|
||||
],
|
||||
"summary": "Get insights about user latency",
|
||||
"operationId": "get-insights-about-user-latency",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserLatencyInsightsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/licenses": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -6956,6 +7006,19 @@ const docTemplate = `{
|
|||
"BuildReasonAutostop"
|
||||
]
|
||||
},
|
||||
"codersdk.ConnectionLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"p50": {
|
||||
"type": "number",
|
||||
"example": 31.312
|
||||
},
|
||||
"p95": {
|
||||
"type": "number",
|
||||
"example": 119.832
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ConvertLoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
@ -8040,6 +8103,15 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InsightsReportInterval": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"day"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"InsightsReportIntervalDay"
|
||||
]
|
||||
},
|
||||
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
@ -9123,6 +9195,50 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateAppUsage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string",
|
||||
"example": "Visual Studio Code"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string"
|
||||
},
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"example": 80500
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"example": "vscode"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.TemplateAppsType"
|
||||
}
|
||||
],
|
||||
"example": "builtin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateAppsType": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"builtin"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"TemplateAppsTypeBuiltin"
|
||||
]
|
||||
},
|
||||
"codersdk.TemplateBuildTimeStats": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
|
@ -9159,6 +9275,77 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateInsightsIntervalReport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_users": {
|
||||
"type": "integer",
|
||||
"example": 14
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"interval": {
|
||||
"$ref": "#/definitions/codersdk.InsightsReportInterval"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateInsightsReport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_users": {
|
||||
"type": "integer",
|
||||
"example": 22
|
||||
},
|
||||
"apps_usage": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.TemplateAppUsage"
|
||||
}
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateInsightsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_reports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.TemplateInsightsIntervalReport"
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"$ref": "#/definitions/codersdk.TemplateInsightsReport"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateRestartRequirement": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -9708,6 +9895,62 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"latency_ms": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionLatency"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatencyInsightsReport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.UserLatency"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatencyInsightsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"$ref": "#/definitions/codersdk.UserLatencyInsightsReport"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLoginType": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -756,6 +756,48 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/insights/templates": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Insights"],
|
||||
"summary": "Get insights about templates",
|
||||
"operationId": "get-insights-about-templates",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.TemplateInsightsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/insights/user-latency": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Insights"],
|
||||
"summary": "Get insights about user latency",
|
||||
"operationId": "get-insights-about-user-latency",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/codersdk.UserLatencyInsightsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/licenses": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -6195,6 +6237,19 @@
|
|||
"BuildReasonAutostop"
|
||||
]
|
||||
},
|
||||
"codersdk.ConnectionLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"p50": {
|
||||
"type": "number",
|
||||
"example": 31.312
|
||||
},
|
||||
"p95": {
|
||||
"type": "number",
|
||||
"example": 119.832
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ConvertLoginRequest": {
|
||||
"type": "object",
|
||||
"required": ["password", "to_type"],
|
||||
|
@ -7220,6 +7275,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.InsightsReportInterval": {
|
||||
"type": "string",
|
||||
"enum": ["day"],
|
||||
"x-enum-varnames": ["InsightsReportIntervalDay"]
|
||||
},
|
||||
"codersdk.IssueReconnectingPTYSignedTokenRequest": {
|
||||
"type": "object",
|
||||
"required": ["agentID", "url"],
|
||||
|
@ -8238,6 +8298,46 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateAppUsage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string",
|
||||
"example": "Visual Studio Code"
|
||||
},
|
||||
"icon": {
|
||||
"type": "string"
|
||||
},
|
||||
"seconds": {
|
||||
"type": "integer",
|
||||
"example": 80500
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"example": "vscode"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.TemplateAppsType"
|
||||
}
|
||||
],
|
||||
"example": "builtin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateAppsType": {
|
||||
"type": "string",
|
||||
"enum": ["builtin"],
|
||||
"x-enum-varnames": ["TemplateAppsTypeBuiltin"]
|
||||
},
|
||||
"codersdk.TemplateBuildTimeStats": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
|
@ -8274,6 +8374,77 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateInsightsIntervalReport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_users": {
|
||||
"type": "integer",
|
||||
"example": 14
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"interval": {
|
||||
"$ref": "#/definitions/codersdk.InsightsReportInterval"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateInsightsReport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"active_users": {
|
||||
"type": "integer",
|
||||
"example": 22
|
||||
},
|
||||
"apps_usage": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.TemplateAppUsage"
|
||||
}
|
||||
},
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateInsightsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"interval_reports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.TemplateInsightsIntervalReport"
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"$ref": "#/definitions/codersdk.TemplateInsightsReport"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.TemplateRestartRequirement": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -8774,6 +8945,62 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatency": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"latency_ms": {
|
||||
"$ref": "#/definitions/codersdk.ConnectionLatency"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatencyInsightsReport": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"end_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"template_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.UserLatency"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLatencyInsightsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report": {
|
||||
"$ref": "#/definitions/codersdk.UserLatencyInsightsReport"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.UserLoginType": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
@ -850,6 +850,8 @@ func New(options *Options) *API {
|
|||
r.Route("/insights", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
r.Get("/daus", api.deploymentDAUs)
|
||||
r.Get("/user-latency", api.insightsUserLatency)
|
||||
r.Get("/templates", api.insightsTemplates)
|
||||
})
|
||||
r.Route("/debug", func(r chi.Router) {
|
||||
r.Use(
|
||||
|
|
|
@ -1173,6 +1173,22 @@ func (q *querier) GetTemplateDAUs(ctx context.Context, arg database.GetTemplateD
|
|||
return q.db.GetTemplateDAUs(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
|
||||
// FIXME: this should maybe be READ rbac.ResourceTemplate or it's own resource.
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetTemplateDailyInsights(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
|
||||
// FIXME: this should maybe be READ rbac.ResourceTemplate or it's own resource.
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return database.GetTemplateInsightsRow{}, err
|
||||
}
|
||||
return q.db.GetTemplateInsights(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetTemplateVersionByID(ctx context.Context, tvid uuid.UUID) (database.TemplateVersion, error) {
|
||||
tv, err := q.db.GetTemplateVersionByID(ctx, tvid)
|
||||
if err != nil {
|
||||
|
@ -1339,6 +1355,13 @@ func (q *querier) GetUserCount(ctx context.Context) (int64, error) {
|
|||
return q.db.GetUserCount(ctx)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetUserLatencyInsights(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceSystem); err != nil {
|
||||
return database.UserLink{}, err
|
||||
|
|
|
@ -1944,6 +1944,129 @@ func (q *FakeQuerier) GetTemplateDAUs(_ context.Context, arg database.GetTemplat
|
|||
return rs, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetTemplateDailyInsights(_ context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type dailyStat struct {
|
||||
startTime, endTime time.Time
|
||||
userSet map[uuid.UUID]struct{}
|
||||
templateIDSet map[uuid.UUID]struct{}
|
||||
}
|
||||
dailyStats := []dailyStat{{arg.StartTime, arg.StartTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})}}
|
||||
for dailyStats[len(dailyStats)-1].endTime.Before(arg.EndTime) {
|
||||
dailyStats = append(dailyStats, dailyStat{dailyStats[len(dailyStats)-1].endTime, dailyStats[len(dailyStats)-1].endTime.AddDate(0, 0, 1), make(map[uuid.UUID]struct{}), make(map[uuid.UUID]struct{})})
|
||||
}
|
||||
if dailyStats[len(dailyStats)-1].endTime.After(arg.EndTime) {
|
||||
dailyStats[len(dailyStats)-1].endTime = arg.EndTime
|
||||
}
|
||||
|
||||
for _, s := range q.workspaceAgentStats {
|
||||
if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) {
|
||||
continue
|
||||
}
|
||||
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
|
||||
continue
|
||||
}
|
||||
if s.ConnectionCount == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ds := range dailyStats {
|
||||
if s.CreatedAt.Before(ds.startTime) || s.CreatedAt.Equal(ds.endTime) || s.CreatedAt.After(ds.endTime) {
|
||||
continue
|
||||
}
|
||||
ds.userSet[s.UserID] = struct{}{}
|
||||
ds.templateIDSet[s.TemplateID] = struct{}{}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var result []database.GetTemplateDailyInsightsRow
|
||||
for _, ds := range dailyStats {
|
||||
templateIDs := make([]uuid.UUID, 0, len(ds.templateIDSet))
|
||||
for templateID := range ds.templateIDSet {
|
||||
templateIDs = append(templateIDs, templateID)
|
||||
}
|
||||
slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool {
|
||||
return a.String() < b.String()
|
||||
})
|
||||
result = append(result, database.GetTemplateDailyInsightsRow{
|
||||
StartTime: ds.startTime,
|
||||
EndTime: ds.endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
ActiveUsers: int64(len(ds.userSet)),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetTemplateInsights(_ context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return database.GetTemplateInsightsRow{}, err
|
||||
}
|
||||
|
||||
templateIDSet := make(map[uuid.UUID]struct{})
|
||||
appUsageIntervalsByUser := make(map[uuid.UUID]map[time.Time]*database.GetTemplateInsightsRow)
|
||||
for _, s := range q.workspaceAgentStats {
|
||||
if s.CreatedAt.Before(arg.StartTime) || s.CreatedAt.Equal(arg.EndTime) || s.CreatedAt.After(arg.EndTime) {
|
||||
continue
|
||||
}
|
||||
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
|
||||
continue
|
||||
}
|
||||
if s.ConnectionCount == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
templateIDSet[s.TemplateID] = struct{}{}
|
||||
if appUsageIntervalsByUser[s.UserID] == nil {
|
||||
appUsageIntervalsByUser[s.UserID] = make(map[time.Time]*database.GetTemplateInsightsRow)
|
||||
}
|
||||
t := s.CreatedAt.Truncate(5 * time.Minute)
|
||||
if _, ok := appUsageIntervalsByUser[s.UserID][t]; !ok {
|
||||
appUsageIntervalsByUser[s.UserID][t] = &database.GetTemplateInsightsRow{}
|
||||
}
|
||||
|
||||
if s.SessionCountJetBrains > 0 {
|
||||
appUsageIntervalsByUser[s.UserID][t].UsageJetbrainsSeconds = 300
|
||||
}
|
||||
if s.SessionCountVSCode > 0 {
|
||||
appUsageIntervalsByUser[s.UserID][t].UsageVscodeSeconds = 300
|
||||
}
|
||||
if s.SessionCountReconnectingPTY > 0 {
|
||||
appUsageIntervalsByUser[s.UserID][t].UsageReconnectingPtySeconds = 300
|
||||
}
|
||||
if s.SessionCountSSH > 0 {
|
||||
appUsageIntervalsByUser[s.UserID][t].UsageSshSeconds = 300
|
||||
}
|
||||
}
|
||||
|
||||
templateIDs := make([]uuid.UUID, 0, len(templateIDSet))
|
||||
for templateID := range templateIDSet {
|
||||
templateIDs = append(templateIDs, templateID)
|
||||
}
|
||||
slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool {
|
||||
return a.String() < b.String()
|
||||
})
|
||||
result := database.GetTemplateInsightsRow{
|
||||
TemplateIDs: templateIDs,
|
||||
ActiveUsers: int64(len(appUsageIntervalsByUser)),
|
||||
}
|
||||
for _, intervals := range appUsageIntervalsByUser {
|
||||
for _, interval := range intervals {
|
||||
result.UsageJetbrainsSeconds += interval.UsageJetbrainsSeconds
|
||||
result.UsageVscodeSeconds += interval.UsageVscodeSeconds
|
||||
result.UsageReconnectingPtySeconds += interval.UsageReconnectingPtySeconds
|
||||
result.UsageSshSeconds += interval.UsageSshSeconds
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetTemplateVersionByID(ctx context.Context, templateVersionID uuid.UUID) (database.TemplateVersion, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
@ -2188,6 +2311,74 @@ func (q *FakeQuerier) GetUserCount(_ context.Context) (int64, error) {
|
|||
return existing, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetUserLatencyInsights(_ context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
|
||||
err := validateDatabaseType(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
latenciesByUserID := make(map[uuid.UUID][]float64)
|
||||
seenTemplatesByUserID := make(map[uuid.UUID]map[uuid.UUID]struct{})
|
||||
for _, s := range q.workspaceAgentStats {
|
||||
if len(arg.TemplateIDs) > 0 && !slices.Contains(arg.TemplateIDs, s.TemplateID) {
|
||||
continue
|
||||
}
|
||||
if !arg.StartTime.Equal(s.CreatedAt) && !(s.CreatedAt.After(arg.StartTime) && s.CreatedAt.Before(arg.EndTime)) {
|
||||
continue
|
||||
}
|
||||
if s.ConnectionCount == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
latenciesByUserID[s.UserID] = append(latenciesByUserID[s.UserID], s.ConnectionMedianLatencyMS)
|
||||
if seenTemplatesByUserID[s.UserID] == nil {
|
||||
seenTemplatesByUserID[s.UserID] = make(map[uuid.UUID]struct{})
|
||||
}
|
||||
seenTemplatesByUserID[s.UserID][s.TemplateID] = struct{}{}
|
||||
}
|
||||
|
||||
tryPercentile := func(fs []float64, p float64) float64 {
|
||||
if len(fs) == 0 {
|
||||
return -1
|
||||
}
|
||||
sort.Float64s(fs)
|
||||
return fs[int(float64(len(fs))*p/100)]
|
||||
}
|
||||
|
||||
var rows []database.GetUserLatencyInsightsRow
|
||||
for userID, latencies := range latenciesByUserID {
|
||||
sort.Float64s(latencies)
|
||||
templateIDSet := seenTemplatesByUserID[userID]
|
||||
templateIDs := make([]uuid.UUID, 0, len(templateIDSet))
|
||||
for templateID := range templateIDSet {
|
||||
templateIDs = append(templateIDs, templateID)
|
||||
}
|
||||
slices.SortFunc(templateIDs, func(a, b uuid.UUID) bool {
|
||||
return a.String() < b.String()
|
||||
})
|
||||
user, err := q.getUserByIDNoLock(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row := database.GetUserLatencyInsightsRow{
|
||||
UserID: userID,
|
||||
Username: user.Username,
|
||||
TemplateIDs: templateIDs,
|
||||
WorkspaceConnectionLatency50: tryPercentile(latencies, 50),
|
||||
WorkspaceConnectionLatency95: tryPercentile(latencies, 95),
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
slices.SortFunc(rows, func(a, b database.GetUserLatencyInsightsRow) bool {
|
||||
return a.UserID.String() < b.UserID.String()
|
||||
})
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) GetUserLinkByLinkedID(_ context.Context, id string) (database.UserLink, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
@ -5333,9 +5524,9 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
|
|||
}
|
||||
}
|
||||
|
||||
if len(arg.TemplateIds) > 0 {
|
||||
if len(arg.TemplateIDs) > 0 {
|
||||
match := false
|
||||
for _, id := range arg.TemplateIds {
|
||||
for _, id := range arg.TemplateIDs {
|
||||
if workspace.TemplateID == id {
|
||||
match = true
|
||||
break
|
||||
|
|
|
@ -599,6 +599,20 @@ func (m metricsStore) GetTemplateDAUs(ctx context.Context, arg database.GetTempl
|
|||
return daus, err
|
||||
}
|
||||
|
||||
func (m metricsStore) GetTemplateDailyInsights(ctx context.Context, arg database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTemplateDailyInsights(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetTemplateDailyInsights").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetTemplateInsights(ctx context.Context, arg database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetTemplateInsights(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetTemplateInsights").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (database.TemplateVersion, error) {
|
||||
start := time.Now()
|
||||
version, err := m.s.GetTemplateVersionByID(ctx, id)
|
||||
|
@ -697,6 +711,13 @@ func (m metricsStore) GetUserCount(ctx context.Context) (int64, error) {
|
|||
return count, err
|
||||
}
|
||||
|
||||
func (m metricsStore) GetUserLatencyInsights(ctx context.Context, arg database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.GetUserLatencyInsights(ctx, arg)
|
||||
m.queryLatencies.WithLabelValues("GetUserLatencyInsights").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (database.UserLink, error) {
|
||||
start := time.Now()
|
||||
link, err := m.s.GetUserLinkByLinkedID(ctx, linkedID)
|
||||
|
|
|
@ -1196,6 +1196,21 @@ func (mr *MockStoreMockRecorder) GetTemplateDAUs(arg0, arg1 interface{}) *gomock
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDAUs", reflect.TypeOf((*MockStore)(nil).GetTemplateDAUs), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetTemplateDailyInsights mocks base method.
|
||||
func (m *MockStore) GetTemplateDailyInsights(arg0 context.Context, arg1 database.GetTemplateDailyInsightsParams) ([]database.GetTemplateDailyInsightsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTemplateDailyInsights", arg0, arg1)
|
||||
ret0, _ := ret[0].([]database.GetTemplateDailyInsightsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTemplateDailyInsights indicates an expected call of GetTemplateDailyInsights.
|
||||
func (mr *MockStoreMockRecorder) GetTemplateDailyInsights(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateDailyInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateDailyInsights), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetTemplateGroupRoles mocks base method.
|
||||
func (m *MockStore) GetTemplateGroupRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateGroup, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -1211,6 +1226,21 @@ func (mr *MockStoreMockRecorder) GetTemplateGroupRoles(arg0, arg1 interface{}) *
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateGroupRoles", reflect.TypeOf((*MockStore)(nil).GetTemplateGroupRoles), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetTemplateInsights mocks base method.
|
||||
func (m *MockStore) GetTemplateInsights(arg0 context.Context, arg1 database.GetTemplateInsightsParams) (database.GetTemplateInsightsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetTemplateInsights", arg0, arg1)
|
||||
ret0, _ := ret[0].(database.GetTemplateInsightsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetTemplateInsights indicates an expected call of GetTemplateInsights.
|
||||
func (mr *MockStoreMockRecorder) GetTemplateInsights(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTemplateInsights", reflect.TypeOf((*MockStore)(nil).GetTemplateInsights), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetTemplateUserRoles mocks base method.
|
||||
func (m *MockStore) GetTemplateUserRoles(arg0 context.Context, arg1 uuid.UUID) ([]database.TemplateUser, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
@ -1436,6 +1466,21 @@ func (mr *MockStoreMockRecorder) GetUserCount(arg0 interface{}) *gomock.Call {
|
|||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserCount", reflect.TypeOf((*MockStore)(nil).GetUserCount), arg0)
|
||||
}
|
||||
|
||||
// GetUserLatencyInsights mocks base method.
|
||||
func (m *MockStore) GetUserLatencyInsights(arg0 context.Context, arg1 database.GetUserLatencyInsightsParams) ([]database.GetUserLatencyInsightsRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetUserLatencyInsights", arg0, arg1)
|
||||
ret0, _ := ret[0].([]database.GetUserLatencyInsightsRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetUserLatencyInsights indicates an expected call of GetUserLatencyInsights.
|
||||
func (mr *MockStoreMockRecorder) GetUserLatencyInsights(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLatencyInsights", reflect.TypeOf((*MockStore)(nil).GetUserLatencyInsights), arg0, arg1)
|
||||
}
|
||||
|
||||
// GetUserLinkByLinkedID mocks base method.
|
||||
func (m *MockStore) GetUserLinkByLinkedID(arg0 context.Context, arg1 string) (database.UserLink, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
|
|
@ -213,7 +213,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
|||
arg.OwnerID,
|
||||
arg.OwnerUsername,
|
||||
arg.TemplateName,
|
||||
pq.Array(arg.TemplateIds),
|
||||
pq.Array(arg.TemplateIDs),
|
||||
arg.Name,
|
||||
arg.HasAgent,
|
||||
arg.AgentInactiveDisconnectTimeoutSeconds,
|
||||
|
|
|
@ -105,6 +105,14 @@ type sqlcQuerier interface {
|
|||
GetTemplateByID(ctx context.Context, id uuid.UUID) (Template, error)
|
||||
GetTemplateByOrganizationAndName(ctx context.Context, arg GetTemplateByOrganizationAndNameParams) (Template, error)
|
||||
GetTemplateDAUs(ctx context.Context, arg GetTemplateDAUsParams) ([]GetTemplateDAUsRow, error)
|
||||
// GetTemplateDailyInsights returns all daily intervals between start and end
|
||||
// time, if end time is a partial day, it will be included in the results and
|
||||
// that interval will be less than 24 hours. If there is no data for a selected
|
||||
// interval/template, it will be included in the results with 0 active users.
|
||||
GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error)
|
||||
// GetTemplateInsights has a granularity of 5 minutes where if a session/app was
|
||||
// in use, we will add 5 minutes to the total usage for that session (per user).
|
||||
GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error)
|
||||
GetTemplateVersionByID(ctx context.Context, id uuid.UUID) (TemplateVersion, error)
|
||||
GetTemplateVersionByJobID(ctx context.Context, jobID uuid.UUID) (TemplateVersion, error)
|
||||
GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error)
|
||||
|
@ -119,6 +127,11 @@ type sqlcQuerier interface {
|
|||
GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error)
|
||||
GetUserByID(ctx context.Context, id uuid.UUID) (User, error)
|
||||
GetUserCount(ctx context.Context) (int64, error)
|
||||
// GetUserLatencyInsights returns the median and 95th percentile connection
|
||||
// latency that users have experienced. The result can be filtered on
|
||||
// template_ids, meaning only user data from workspaces based on those templates
|
||||
// will be included.
|
||||
GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error)
|
||||
GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error)
|
||||
GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error)
|
||||
// This will never return deleted users.
|
||||
|
|
|
@ -1375,6 +1375,228 @@ func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDPar
|
|||
return i, err
|
||||
}
|
||||
|
||||
const getTemplateDailyInsights = `-- name: GetTemplateDailyInsights :many
|
||||
WITH d AS (
|
||||
-- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series.
|
||||
SELECT generate_series($1::timestamptz, $2::timestamptz, '1 day'::interval) AS d
|
||||
), ts AS (
|
||||
SELECT
|
||||
d::timestamptz AS from_,
|
||||
CASE WHEN (d + '1 day'::interval)::timestamptz <= $2::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE $2::timestamptz END AS to_
|
||||
FROM d
|
||||
), usage_by_day AS (
|
||||
SELECT
|
||||
ts.from_, ts.to_,
|
||||
was.user_id,
|
||||
array_agg(was.template_id) AS template_ids
|
||||
FROM ts
|
||||
LEFT JOIN workspace_agent_stats was ON (
|
||||
was.created_at >= ts.from_
|
||||
AND was.created_at < ts.to_
|
||||
AND was.connection_count > 0
|
||||
AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END
|
||||
)
|
||||
GROUP BY ts.from_, ts.to_, was.user_id
|
||||
), template_ids AS (
|
||||
SELECT array_agg(DISTINCT template_id) AS ids
|
||||
FROM usage_by_day, unnest(template_ids) template_id
|
||||
WHERE template_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT
|
||||
from_ AS start_time,
|
||||
to_ AS end_time,
|
||||
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
|
||||
COUNT(DISTINCT user_id) AS active_users
|
||||
FROM usage_by_day, unnest(template_ids) as template_id
|
||||
GROUP BY from_, to_
|
||||
`
|
||||
|
||||
type GetTemplateDailyInsightsParams struct {
|
||||
StartTime time.Time `db:"start_time" json:"start_time"`
|
||||
EndTime time.Time `db:"end_time" json:"end_time"`
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
}
|
||||
|
||||
type GetTemplateDailyInsightsRow struct {
|
||||
StartTime time.Time `db:"start_time" json:"start_time"`
|
||||
EndTime time.Time `db:"end_time" json:"end_time"`
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
ActiveUsers int64 `db:"active_users" json:"active_users"`
|
||||
}
|
||||
|
||||
// GetTemplateDailyInsights returns all daily intervals between start and end
|
||||
// time, if end time is a partial day, it will be included in the results and
|
||||
// that interval will be less than 24 hours. If there is no data for a selected
|
||||
// interval/template, it will be included in the results with 0 active users.
|
||||
func (q *sqlQuerier) GetTemplateDailyInsights(ctx context.Context, arg GetTemplateDailyInsightsParams) ([]GetTemplateDailyInsightsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getTemplateDailyInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetTemplateDailyInsightsRow
|
||||
for rows.Next() {
|
||||
var i GetTemplateDailyInsightsRow
|
||||
if err := rows.Scan(
|
||||
&i.StartTime,
|
||||
&i.EndTime,
|
||||
pq.Array(&i.TemplateIDs),
|
||||
&i.ActiveUsers,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getTemplateInsights = `-- name: GetTemplateInsights :one
|
||||
WITH d AS (
|
||||
SELECT generate_series($1::timestamptz, $2::timestamptz, '5 minute'::interval) AS d
|
||||
), ts AS (
|
||||
SELECT
|
||||
d::timestamptz AS from_,
|
||||
(d + '5 minute'::interval)::timestamptz AS to_,
|
||||
EXTRACT(epoch FROM '5 minute'::interval) AS seconds
|
||||
FROM d
|
||||
), usage_by_user AS (
|
||||
SELECT
|
||||
ts.from_,
|
||||
ts.to_,
|
||||
was.user_id,
|
||||
array_agg(was.template_id) AS template_ids,
|
||||
CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds,
|
||||
CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds,
|
||||
CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds,
|
||||
CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds
|
||||
FROM ts
|
||||
JOIN workspace_agent_stats was ON (
|
||||
was.created_at >= ts.from_
|
||||
AND was.created_at < ts.to_
|
||||
AND was.connection_count > 0
|
||||
AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN was.template_id = ANY($3::uuid[]) ELSE TRUE END
|
||||
)
|
||||
GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id
|
||||
), template_ids AS (
|
||||
SELECT array_agg(DISTINCT template_id) AS ids
|
||||
FROM usage_by_user, unnest(template_ids) template_id
|
||||
WHERE template_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT
|
||||
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
|
||||
COUNT(DISTINCT user_id) AS active_users,
|
||||
COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds,
|
||||
COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds,
|
||||
COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds,
|
||||
COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds
|
||||
FROM usage_by_user
|
||||
`
|
||||
|
||||
type GetTemplateInsightsParams struct {
|
||||
StartTime time.Time `db:"start_time" json:"start_time"`
|
||||
EndTime time.Time `db:"end_time" json:"end_time"`
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
}
|
||||
|
||||
type GetTemplateInsightsRow struct {
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
ActiveUsers int64 `db:"active_users" json:"active_users"`
|
||||
UsageVscodeSeconds int64 `db:"usage_vscode_seconds" json:"usage_vscode_seconds"`
|
||||
UsageJetbrainsSeconds int64 `db:"usage_jetbrains_seconds" json:"usage_jetbrains_seconds"`
|
||||
UsageReconnectingPtySeconds int64 `db:"usage_reconnecting_pty_seconds" json:"usage_reconnecting_pty_seconds"`
|
||||
UsageSshSeconds int64 `db:"usage_ssh_seconds" json:"usage_ssh_seconds"`
|
||||
}
|
||||
|
||||
// GetTemplateInsights has a granularity of 5 minutes where if a session/app was
|
||||
// in use, we will add 5 minutes to the total usage for that session (per user).
|
||||
func (q *sqlQuerier) GetTemplateInsights(ctx context.Context, arg GetTemplateInsightsParams) (GetTemplateInsightsRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getTemplateInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs))
|
||||
var i GetTemplateInsightsRow
|
||||
err := row.Scan(
|
||||
pq.Array(&i.TemplateIDs),
|
||||
&i.ActiveUsers,
|
||||
&i.UsageVscodeSeconds,
|
||||
&i.UsageJetbrainsSeconds,
|
||||
&i.UsageReconnectingPtySeconds,
|
||||
&i.UsageSshSeconds,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserLatencyInsights = `-- name: GetUserLatencyInsights :many
|
||||
SELECT
|
||||
workspace_agent_stats.user_id,
|
||||
users.username,
|
||||
array_agg(DISTINCT template_id)::uuid[] AS template_ids,
|
||||
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
|
||||
JOIN users ON (users.id = workspace_agent_stats.user_id)
|
||||
WHERE
|
||||
workspace_agent_stats.created_at >= $1
|
||||
AND workspace_agent_stats.created_at < $2
|
||||
AND workspace_agent_stats.connection_median_latency_ms > 0
|
||||
AND workspace_agent_stats.connection_count > 0
|
||||
AND CASE WHEN COALESCE(array_length($3::uuid[], 1), 0) > 0 THEN template_id = ANY($3::uuid[]) ELSE TRUE END
|
||||
GROUP BY workspace_agent_stats.user_id, users.username
|
||||
ORDER BY user_id ASC
|
||||
`
|
||||
|
||||
type GetUserLatencyInsightsParams struct {
|
||||
StartTime time.Time `db:"start_time" json:"start_time"`
|
||||
EndTime time.Time `db:"end_time" json:"end_time"`
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
}
|
||||
|
||||
type GetUserLatencyInsightsRow struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Username string `db:"username" json:"username"`
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
WorkspaceConnectionLatency50 float64 `db:"workspace_connection_latency_50" json:"workspace_connection_latency_50"`
|
||||
WorkspaceConnectionLatency95 float64 `db:"workspace_connection_latency_95" json:"workspace_connection_latency_95"`
|
||||
}
|
||||
|
||||
// GetUserLatencyInsights returns the median and 95th percentile connection
|
||||
// latency that users have experienced. The result can be filtered on
|
||||
// template_ids, meaning only user data from workspaces based on those templates
|
||||
// will be included.
|
||||
func (q *sqlQuerier) GetUserLatencyInsights(ctx context.Context, arg GetUserLatencyInsightsParams) ([]GetUserLatencyInsightsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getUserLatencyInsights, arg.StartTime, arg.EndTime, pq.Array(arg.TemplateIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetUserLatencyInsightsRow
|
||||
for rows.Next() {
|
||||
var i GetUserLatencyInsightsRow
|
||||
if err := rows.Scan(
|
||||
&i.UserID,
|
||||
&i.Username,
|
||||
pq.Array(&i.TemplateIDs),
|
||||
&i.WorkspaceConnectionLatency50,
|
||||
&i.WorkspaceConnectionLatency95,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const deleteLicense = `-- name: DeleteLicense :one
|
||||
DELETE
|
||||
FROM licenses
|
||||
|
@ -8536,7 +8758,7 @@ type GetWorkspacesParams struct {
|
|||
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
|
||||
OwnerUsername string `db:"owner_username" json:"owner_username"`
|
||||
TemplateName string `db:"template_name" json:"template_name"`
|
||||
TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
|
||||
Name string `db:"name" json:"name"`
|
||||
HasAgent string `db:"has_agent" json:"has_agent"`
|
||||
AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"`
|
||||
|
@ -8571,7 +8793,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
|
|||
arg.OwnerID,
|
||||
arg.OwnerUsername,
|
||||
arg.TemplateName,
|
||||
pq.Array(arg.TemplateIds),
|
||||
pq.Array(arg.TemplateIDs),
|
||||
arg.Name,
|
||||
arg.HasAgent,
|
||||
arg.AgentInactiveDisconnectTimeoutSeconds,
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
-- name: GetUserLatencyInsights :many
|
||||
-- GetUserLatencyInsights returns the median and 95th percentile connection
|
||||
-- latency that users have experienced. The result can be filtered on
|
||||
-- template_ids, meaning only user data from workspaces based on those templates
|
||||
-- will be included.
|
||||
SELECT
|
||||
workspace_agent_stats.user_id,
|
||||
users.username,
|
||||
array_agg(DISTINCT template_id)::uuid[] AS template_ids,
|
||||
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
|
||||
JOIN users ON (users.id = workspace_agent_stats.user_id)
|
||||
WHERE
|
||||
workspace_agent_stats.created_at >= @start_time
|
||||
AND workspace_agent_stats.created_at < @end_time
|
||||
AND workspace_agent_stats.connection_median_latency_ms > 0
|
||||
AND workspace_agent_stats.connection_count > 0
|
||||
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
|
||||
GROUP BY workspace_agent_stats.user_id, users.username
|
||||
ORDER BY user_id ASC;
|
||||
|
||||
-- name: GetTemplateInsights :one
|
||||
-- GetTemplateInsights has a granularity of 5 minutes where if a session/app was
|
||||
-- in use, we will add 5 minutes to the total usage for that session (per user).
|
||||
WITH d AS (
|
||||
SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '5 minute'::interval) AS d
|
||||
), ts AS (
|
||||
SELECT
|
||||
d::timestamptz AS from_,
|
||||
(d + '5 minute'::interval)::timestamptz AS to_,
|
||||
EXTRACT(epoch FROM '5 minute'::interval) AS seconds
|
||||
FROM d
|
||||
), usage_by_user AS (
|
||||
SELECT
|
||||
ts.from_,
|
||||
ts.to_,
|
||||
was.user_id,
|
||||
array_agg(was.template_id) AS template_ids,
|
||||
CASE WHEN SUM(was.session_count_vscode) > 0 THEN ts.seconds ELSE 0 END AS usage_vscode_seconds,
|
||||
CASE WHEN SUM(was.session_count_jetbrains) > 0 THEN ts.seconds ELSE 0 END AS usage_jetbrains_seconds,
|
||||
CASE WHEN SUM(was.session_count_reconnecting_pty) > 0 THEN ts.seconds ELSE 0 END AS usage_reconnecting_pty_seconds,
|
||||
CASE WHEN SUM(was.session_count_ssh) > 0 THEN ts.seconds ELSE 0 END AS usage_ssh_seconds
|
||||
FROM ts
|
||||
JOIN workspace_agent_stats was ON (
|
||||
was.created_at >= ts.from_
|
||||
AND was.created_at < ts.to_
|
||||
AND was.connection_count > 0
|
||||
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
|
||||
)
|
||||
GROUP BY ts.from_, ts.to_, ts.seconds, was.user_id
|
||||
), template_ids AS (
|
||||
SELECT array_agg(DISTINCT template_id) AS ids
|
||||
FROM usage_by_user, unnest(template_ids) template_id
|
||||
WHERE template_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT
|
||||
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
|
||||
COUNT(DISTINCT user_id) AS active_users,
|
||||
COALESCE(SUM(usage_vscode_seconds), 0)::bigint AS usage_vscode_seconds,
|
||||
COALESCE(SUM(usage_jetbrains_seconds), 0)::bigint AS usage_jetbrains_seconds,
|
||||
COALESCE(SUM(usage_reconnecting_pty_seconds), 0)::bigint AS usage_reconnecting_pty_seconds,
|
||||
COALESCE(SUM(usage_ssh_seconds), 0)::bigint AS usage_ssh_seconds
|
||||
FROM usage_by_user;
|
||||
|
||||
-- name: GetTemplateDailyInsights :many
|
||||
-- GetTemplateDailyInsights returns all daily intervals between start and end
|
||||
-- time, if end time is a partial day, it will be included in the results and
|
||||
-- that interval will be less than 24 hours. If there is no data for a selected
|
||||
-- interval/template, it will be included in the results with 0 active users.
|
||||
WITH d AS (
|
||||
-- sqlc workaround, use SELECT generate_series instead of SELECT * FROM generate_series.
|
||||
SELECT generate_series(@start_time::timestamptz, @end_time::timestamptz, '1 day'::interval) AS d
|
||||
), ts AS (
|
||||
SELECT
|
||||
d::timestamptz AS from_,
|
||||
CASE WHEN (d + '1 day'::interval)::timestamptz <= @end_time::timestamptz THEN (d + '1 day'::interval)::timestamptz ELSE @end_time::timestamptz END AS to_
|
||||
FROM d
|
||||
), usage_by_day AS (
|
||||
SELECT
|
||||
ts.*,
|
||||
was.user_id,
|
||||
array_agg(was.template_id) AS template_ids
|
||||
FROM ts
|
||||
LEFT JOIN workspace_agent_stats was ON (
|
||||
was.created_at >= ts.from_
|
||||
AND was.created_at < ts.to_
|
||||
AND was.connection_count > 0
|
||||
AND CASE WHEN COALESCE(array_length(@template_ids::uuid[], 1), 0) > 0 THEN was.template_id = ANY(@template_ids::uuid[]) ELSE TRUE END
|
||||
)
|
||||
GROUP BY ts.from_, ts.to_, was.user_id
|
||||
), template_ids AS (
|
||||
SELECT array_agg(DISTINCT template_id) AS ids
|
||||
FROM usage_by_day, unnest(template_ids) template_id
|
||||
WHERE template_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT
|
||||
from_ AS start_time,
|
||||
to_ AS end_time,
|
||||
COALESCE((SELECT ids FROM template_ids), '{}')::uuid[] AS template_ids,
|
||||
COUNT(DISTINCT user_id) AS active_users
|
||||
FROM usage_by_day, unnest(template_ids) as template_id
|
||||
GROUP BY from_, to_;
|
|
@ -69,6 +69,7 @@ overrides:
|
|||
inactivity_ttl: InactivityTTL
|
||||
eof: EOF
|
||||
locked_ttl: LockedTTL
|
||||
template_ids: TemplateIDs
|
||||
|
||||
sql:
|
||||
- schema: "./dump.sql"
|
||||
|
|
|
@ -1,13 +1,24 @@
|
|||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// Duplicated in codersdk.
|
||||
const insightsTimeLayout = time.RFC3339
|
||||
|
||||
// @Summary Get deployment DAUs
|
||||
// @ID get-deployment-daus
|
||||
// @Security CoderSessionToken
|
||||
|
@ -43,3 +54,358 @@ func (api *API) deploymentDAUs(rw http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// @Summary Get insights about user latency
|
||||
// @ID get-insights-about-user-latency
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Insights
|
||||
// @Success 200 {object} codersdk.UserLatencyInsightsResponse
|
||||
// @Router /insights/user-latency [get]
|
||||
func (api *API) insightsUserLatency(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentValues) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
p := httpapi.NewQueryParamParser().
|
||||
Required("start_time").
|
||||
Required("end_time")
|
||||
vals := r.URL.Query()
|
||||
var (
|
||||
// The QueryParamParser does not preserve timezone, so we need
|
||||
// to parse the time ourselves.
|
||||
startTimeString = p.String(vals, "", "start_time")
|
||||
endTimeString = p.String(vals, "", "end_time")
|
||||
templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids")
|
||||
)
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameters have invalid values.",
|
||||
Validations: p.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := api.Database.GetUserLatencyInsights(ctx, database.GetUserLatencyInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching user latency.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
templateIDSet := make(map[uuid.UUID]struct{})
|
||||
userLatencies := make([]codersdk.UserLatency, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
for _, templateID := range row.TemplateIDs {
|
||||
templateIDSet[templateID] = struct{}{}
|
||||
}
|
||||
userLatencies = append(userLatencies, codersdk.UserLatency{
|
||||
TemplateIDs: row.TemplateIDs,
|
||||
UserID: row.UserID,
|
||||
Username: row.Username,
|
||||
LatencyMS: codersdk.ConnectionLatency{
|
||||
P50: row.WorkspaceConnectionLatency50,
|
||||
P95: row.WorkspaceConnectionLatency95,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TemplateIDs that contributed to the data.
|
||||
seenTemplateIDs := make([]uuid.UUID, 0, len(templateIDSet))
|
||||
for templateID := range templateIDSet {
|
||||
seenTemplateIDs = append(seenTemplateIDs, templateID)
|
||||
}
|
||||
slices.SortFunc(seenTemplateIDs, func(a, b uuid.UUID) bool {
|
||||
return a.String() < b.String()
|
||||
})
|
||||
|
||||
resp := codersdk.UserLatencyInsightsResponse{
|
||||
Report: codersdk.UserLatencyInsightsReport{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: seenTemplateIDs,
|
||||
Users: userLatencies,
|
||||
},
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// @Summary Get insights about templates
|
||||
// @ID get-insights-about-templates
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Insights
|
||||
// @Success 200 {object} codersdk.TemplateInsightsResponse
|
||||
// @Router /insights/templates [get]
|
||||
func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if !api.Authorize(r, rbac.ActionRead, rbac.ResourceDeploymentValues) {
|
||||
httpapi.Forbidden(rw)
|
||||
return
|
||||
}
|
||||
|
||||
p := httpapi.NewQueryParamParser().
|
||||
Required("start_time").
|
||||
Required("end_time")
|
||||
vals := r.URL.Query()
|
||||
var (
|
||||
// The QueryParamParser does not preserve timezone, so we need
|
||||
// to parse the time ourselves.
|
||||
startTimeString = p.String(vals, "", "start_time")
|
||||
endTimeString = p.String(vals, "", "end_time")
|
||||
intervalString = p.String(vals, "", "interval")
|
||||
templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids")
|
||||
)
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameters have invalid values.",
|
||||
Validations: p.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
startTime, endTime, ok := parseInsightsStartAndEndTime(ctx, rw, startTimeString, endTimeString)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
interval, ok := verifyInsightsInterval(ctx, rw, intervalString)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var usage database.GetTemplateInsightsRow
|
||||
var dailyUsage []database.GetTemplateDailyInsightsRow
|
||||
// Use a transaction to ensure that we get consistent data between
|
||||
// the full and interval report.
|
||||
err := api.Database.InTx(func(db database.Store) error {
|
||||
var err error
|
||||
|
||||
if interval != "" {
|
||||
dailyUsage, err = db.GetTemplateDailyInsights(ctx, database.GetTemplateDailyInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template daily insights: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
usage, err = db.GetTemplateInsights(ctx, database.GetTemplateInsightsParams{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: templateIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template insights: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching template insights.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp := codersdk.TemplateInsightsResponse{
|
||||
Report: codersdk.TemplateInsightsReport{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
ActiveUsers: usage.ActiveUsers,
|
||||
AppsUsage: convertTemplateInsightsBuiltinApps(usage),
|
||||
},
|
||||
IntervalReports: []codersdk.TemplateInsightsIntervalReport{},
|
||||
}
|
||||
for _, row := range dailyUsage {
|
||||
resp.IntervalReports = append(resp.IntervalReports, codersdk.TemplateInsightsIntervalReport{
|
||||
StartTime: row.StartTime,
|
||||
EndTime: row.EndTime,
|
||||
Interval: interval,
|
||||
TemplateIDs: row.TemplateIDs,
|
||||
ActiveUsers: row.ActiveUsers,
|
||||
})
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// convertTemplateInsightsBuiltinApps builds the list of builtin apps from the
|
||||
// database row, these are apps that are implicitly a part of all templates.
|
||||
func convertTemplateInsightsBuiltinApps(usage database.GetTemplateInsightsRow) []codersdk.TemplateAppUsage {
|
||||
return []codersdk.TemplateAppUsage{
|
||||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "Visual Studio Code",
|
||||
Slug: "vscode",
|
||||
Icon: "/icons/code.svg",
|
||||
Seconds: usage.UsageVscodeSeconds,
|
||||
},
|
||||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "JetBrains",
|
||||
Slug: "jetbrains",
|
||||
Icon: "/icons/intellij.svg",
|
||||
Seconds: usage.UsageJetbrainsSeconds,
|
||||
},
|
||||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "Web Terminal",
|
||||
Slug: "reconnecting-pty",
|
||||
Icon: "/icons/terminal.svg",
|
||||
Seconds: usage.UsageReconnectingPtySeconds,
|
||||
},
|
||||
{
|
||||
TemplateIDs: usage.TemplateIDs,
|
||||
Type: codersdk.TemplateAppsTypeBuiltin,
|
||||
DisplayName: "SSH",
|
||||
Slug: "ssh",
|
||||
Icon: "/icons/terminal.svg",
|
||||
Seconds: usage.UsageSshSeconds,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// parseInsightsStartAndEndTime parses the start and end time query parameters
|
||||
// and returns the parsed values. The client provided timezone must be preserved
|
||||
// when parsing the time. Verification is performed so that the start and end
|
||||
// time are not zero and that the end time is not before the start time. The
|
||||
// clock must be set to 00:00:00, except for "today", where end time is allowed
|
||||
// to provide the hour of the day (e.g. 14:00:00).
|
||||
func parseInsightsStartAndEndTime(ctx context.Context, rw http.ResponseWriter, startTimeString, endTimeString string) (startTime, endTime time.Time, ok bool) {
|
||||
now := time.Now()
|
||||
|
||||
for _, qp := range []struct {
|
||||
name, value string
|
||||
dest *time.Time
|
||||
}{
|
||||
{"start_time", startTimeString, &startTime},
|
||||
{"end_time", endTimeString, &endTime},
|
||||
} {
|
||||
t, err := time.Parse(insightsTimeLayout, qp.value)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: qp.name,
|
||||
Detail: fmt.Sprintf("Query param %q must be a valid date format (%s): %s", qp.name, insightsTimeLayout, err.Error()),
|
||||
},
|
||||
},
|
||||
})
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
if t.IsZero() {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: qp.name,
|
||||
Detail: fmt.Sprintf("Query param %q must not be zero", qp.name),
|
||||
},
|
||||
},
|
||||
})
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
// Round upwards one hour to ensure we can fetch the latest data.
|
||||
if t.After(now.Truncate(time.Hour).Add(time.Hour)) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: qp.name,
|
||||
Detail: fmt.Sprintf("Query param %q must not be in the future", qp.name),
|
||||
},
|
||||
},
|
||||
})
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
ensureZeroHour := true
|
||||
if qp.name == "end_time" {
|
||||
ey, em, ed := t.Date()
|
||||
ty, tm, td := now.Date()
|
||||
|
||||
ensureZeroHour = ey != ty || em != tm || ed != td
|
||||
}
|
||||
h, m, s := t.Clock()
|
||||
if ensureZeroHour && (h != 0 || m != 0 || s != 0) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: qp.name,
|
||||
Detail: fmt.Sprintf("Query param %q must have the clock set to 00:00:00", qp.name),
|
||||
},
|
||||
},
|
||||
})
|
||||
return time.Time{}, time.Time{}, false
|
||||
} else if m != 0 || s != 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: qp.name,
|
||||
Detail: fmt.Sprintf("Query param %q must have the clock set to %02d:00:00", qp.name, h),
|
||||
},
|
||||
},
|
||||
})
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
*qp.dest = t
|
||||
}
|
||||
if endTime.Before(startTime) {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "end_time",
|
||||
Detail: fmt.Sprintf("Query param %q must be after than %q", "end_time", "start_time"),
|
||||
},
|
||||
},
|
||||
})
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
|
||||
return startTime, endTime, true
|
||||
}
|
||||
|
||||
func verifyInsightsInterval(ctx context.Context, rw http.ResponseWriter, intervalString string) (codersdk.InsightsReportInterval, bool) {
|
||||
switch v := codersdk.InsightsReportInterval(intervalString); v {
|
||||
case codersdk.InsightsReportIntervalDay, "":
|
||||
return v, true
|
||||
default:
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Query parameter has invalid value.",
|
||||
Validations: []codersdk.ValidationError{
|
||||
{
|
||||
Field: "interval",
|
||||
Detail: fmt.Sprintf("must be one of %v", []codersdk.InsightsReportInterval{codersdk.InsightsReportIntervalDay}),
|
||||
},
|
||||
},
|
||||
})
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
package coderd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_parseInsightsStartAndEndTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
layout := insightsTimeLayout
|
||||
now := time.Now().UTC()
|
||||
y, m, d := now.Date()
|
||||
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
thisHour := time.Date(y, m, d, now.Hour(), 0, 0, 0, time.UTC)
|
||||
thisHourRoundUp := thisHour.Add(time.Hour)
|
||||
|
||||
helsinki, err := time.LoadLocation("Europe/Helsinki")
|
||||
require.NoError(t, err)
|
||||
|
||||
type args struct {
|
||||
startTime string
|
||||
endTime string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantStartTime time.Time
|
||||
wantEndTime time.Time
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
name: "Week",
|
||||
args: args{
|
||||
startTime: "2023-07-10T00:00:00Z",
|
||||
endTime: "2023-07-17T00:00:00Z",
|
||||
},
|
||||
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, time.UTC),
|
||||
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, time.UTC),
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "Today",
|
||||
args: args{
|
||||
startTime: today.Format(layout),
|
||||
endTime: thisHour.Format(layout),
|
||||
},
|
||||
wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC),
|
||||
wantEndTime: time.Date(2023, 7, today.Day(), thisHour.Hour(), 0, 0, 0, time.UTC),
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "Today with minutes and seconds",
|
||||
args: args{
|
||||
startTime: today.Format(layout),
|
||||
endTime: thisHour.Add(time.Minute + time.Second).Format(layout),
|
||||
},
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "Today (hour round up)",
|
||||
args: args{
|
||||
startTime: today.Format(layout),
|
||||
endTime: thisHourRoundUp.Format(layout),
|
||||
},
|
||||
wantStartTime: time.Date(2023, 7, today.Day(), 0, 0, 0, 0, time.UTC),
|
||||
wantEndTime: time.Date(2023, 7, today.Day(), thisHourRoundUp.Hour(), 0, 0, 0, time.UTC),
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "Other timezone week",
|
||||
args: args{
|
||||
startTime: "2023-07-10T00:00:00+03:00",
|
||||
endTime: "2023-07-17T00:00:00+03:00",
|
||||
},
|
||||
wantStartTime: time.Date(2023, 7, 10, 0, 0, 0, 0, helsinki),
|
||||
wantEndTime: time.Date(2023, 7, 17, 0, 0, 0, 0, helsinki),
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "Daylight savings time",
|
||||
args: args{
|
||||
startTime: "2023-03-26T00:00:00+02:00",
|
||||
endTime: "2023-03-27T00:00:00+03:00",
|
||||
},
|
||||
wantStartTime: time.Date(2023, 3, 26, 0, 0, 0, 0, helsinki),
|
||||
wantEndTime: time.Date(2023, 3, 27, 0, 0, 0, 0, helsinki),
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "Bad format",
|
||||
args: args{
|
||||
startTime: "2023-07-10",
|
||||
endTime: "2023-07-17",
|
||||
},
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "Zero time",
|
||||
args: args{
|
||||
startTime: (time.Time{}).Format(layout),
|
||||
endTime: (time.Time{}).Format(layout),
|
||||
},
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "Time in future",
|
||||
args: args{
|
||||
startTime: today.AddDate(0, 0, 1).Format(layout),
|
||||
endTime: today.AddDate(0, 0, 2).Format(layout),
|
||||
},
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "End before start",
|
||||
args: args{
|
||||
startTime: today.Format(layout),
|
||||
endTime: today.AddDate(0, 0, -1).Format(layout),
|
||||
},
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
gotStartTime, gotEndTime, gotOk := parseInsightsStartAndEndTime(context.Background(), rw, tt.args.startTime, tt.args.endTime)
|
||||
|
||||
if !assert.Equal(t, tt.wantOk, gotOk) {
|
||||
//nolint:bodyclose
|
||||
t.Log("Status: ", rw.Result().StatusCode)
|
||||
t.Log("Body: ", rw.Body.String())
|
||||
}
|
||||
// assert.Equal is unable to test time equality with different
|
||||
// (but same) locations because the *time.Location names differ
|
||||
// between LoadLocation and Parse, so we use assert.WithinDuration.
|
||||
assert.WithinDuration(t, tt.wantStartTime, gotStartTime, 0)
|
||||
assert.True(t, tt.wantStartTime.Equal(gotStartTime))
|
||||
assert.WithinDuration(t, tt.wantEndTime, gotEndTime, 0)
|
||||
assert.True(t, tt.wantEndTime.Equal(gotEndTime))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -2,12 +2,14 @@ package coderd_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/agent"
|
||||
|
@ -100,3 +102,275 @@ func TestDeploymentInsights(t *testing.T) {
|
|||
res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestUserLatencyInsights(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
})
|
||||
|
||||
// Create two users, one that will appear in the report and another that
|
||||
// won't (due to not having/using a workspace).
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
_, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Start an agent so that we can generate stats.
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Logger: logger.Named("agent"),
|
||||
Client: agentClient,
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
// Start must be at the beginning of the day, initialize it early in case
|
||||
// the day changes so that we get the relevant stats faster.
|
||||
y, m, d := time.Now().UTC().Date()
|
||||
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Connect to the agent to generate usage/latency stats.
|
||||
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{
|
||||
Logger: logger.Named("client"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
sshConn, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshConn.Close()
|
||||
|
||||
sess, err := sshConn.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer sess.Close()
|
||||
|
||||
r, w := io.Pipe()
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
sess.Stdin = r
|
||||
err = sess.Start("cat")
|
||||
require.NoError(t, err)
|
||||
|
||||
var userLatencies codersdk.UserLatencyInsightsResponse
|
||||
require.Eventuallyf(t, func() bool {
|
||||
userLatencies, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
|
||||
StartTime: today,
|
||||
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour), // Round up to include the current hour.
|
||||
TemplateIDs: []uuid.UUID{template.ID},
|
||||
})
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
return len(userLatencies.Report.Users) > 0 && userLatencies.Report.Users[0].LatencyMS.P50 > 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast, "user latency is missing")
|
||||
|
||||
// We got our latency data, close the connection.
|
||||
_ = sess.Close()
|
||||
_ = sshConn.Close()
|
||||
|
||||
require.Len(t, userLatencies.Report.Users, 1, "want only 1 user")
|
||||
require.Equal(t, userLatencies.Report.Users[0].UserID, user.UserID, "want user id to match")
|
||||
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P50, float64(0), "want p50 to be greater than 0")
|
||||
assert.Greater(t, userLatencies.Report.Users[0].LatencyMS.P95, float64(0), "want p95 to be greater than 0")
|
||||
}
|
||||
|
||||
func TestUserLatencyInsights_BadRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
y, m, d := time.Now().UTC().Date()
|
||||
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
|
||||
StartTime: today,
|
||||
EndTime: today.AddDate(0, 0, -1),
|
||||
})
|
||||
assert.Error(t, err, "want error for end time before start time")
|
||||
|
||||
_, err = client.UserLatencyInsights(ctx, codersdk.UserLatencyInsightsRequest{
|
||||
StartTime: today.AddDate(0, 0, -7),
|
||||
EndTime: today.Add(-time.Hour),
|
||||
})
|
||||
assert.Error(t, err, "want error for end time partial day when not today")
|
||||
}
|
||||
|
||||
func TestTemplateInsights(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slogtest.Make(t, nil)
|
||||
opts := &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
AgentStatsRefreshInterval: time.Millisecond * 100,
|
||||
}
|
||||
client := coderdtest.New(t, opts)
|
||||
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||
})
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
require.Empty(t, template.BuildTimeStats[codersdk.WorkspaceTransitionStart])
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
// Start an agent so that we can generate stats.
|
||||
agentClient := agentsdk.New(client.URL)
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Logger: logger.Named("agent"),
|
||||
Client: agentClient,
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
// Start must be at the beginning of the day, initialize it early in case
|
||||
// the day changes so that we get the relevant stats faster.
|
||||
y, m, d := time.Now().UTC().Date()
|
||||
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Connect to the agent to generate usage/latency stats.
|
||||
conn, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, &codersdk.DialWorkspaceAgentOptions{
|
||||
Logger: logger.Named("client"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
sshConn, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshConn.Close()
|
||||
|
||||
// Start an SSH session to generate SSH usage stats.
|
||||
sess, err := sshConn.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer sess.Close()
|
||||
|
||||
r, w := io.Pipe()
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
sess.Stdin = r
|
||||
err = sess.Start("cat")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Start an rpty session to generate rpty usage stats.
|
||||
rpty, err := client.WorkspaceAgentReconnectingPTY(ctx, codersdk.WorkspaceAgentReconnectingPTYOpts{
|
||||
AgentID: resources[0].Agents[0].ID,
|
||||
Reconnect: uuid.New(),
|
||||
Width: 80,
|
||||
Height: 24,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer rpty.Close()
|
||||
|
||||
var resp codersdk.TemplateInsightsResponse
|
||||
var req codersdk.TemplateInsightsRequest
|
||||
waitForAppSeconds := func(slug string) func() bool {
|
||||
return func() bool {
|
||||
req = codersdk.TemplateInsightsRequest{
|
||||
StartTime: today,
|
||||
EndTime: time.Now().UTC().Truncate(time.Hour).Add(time.Hour),
|
||||
Interval: codersdk.InsightsReportIntervalDay,
|
||||
}
|
||||
resp, err = client.TemplateInsights(ctx, req)
|
||||
if !assert.NoError(t, err) {
|
||||
return false
|
||||
}
|
||||
|
||||
if slices.IndexFunc(resp.Report.AppsUsage, func(au codersdk.TemplateAppUsage) bool {
|
||||
return au.Slug == slug && au.Seconds > 0
|
||||
}) != -1 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
require.Eventually(t, waitForAppSeconds("reconnecting-pty"), testutil.WaitShort, testutil.IntervalFast, "reconnecting-pty seconds missing")
|
||||
require.Eventually(t, waitForAppSeconds("ssh"), testutil.WaitShort, testutil.IntervalFast, "ssh seconds missing")
|
||||
|
||||
// We got our data, close down sessions and connections.
|
||||
_ = rpty.Close()
|
||||
_ = sess.Close()
|
||||
_ = sshConn.Close()
|
||||
|
||||
assert.WithinDuration(t, req.StartTime, resp.Report.StartTime, 0)
|
||||
assert.WithinDuration(t, req.EndTime, resp.Report.EndTime, 0)
|
||||
assert.Equal(t, resp.Report.ActiveUsers, int64(1), "want one active user")
|
||||
for _, app := range resp.Report.AppsUsage {
|
||||
if slices.Contains([]string{"reconnecting-pty", "ssh"}, app.Slug) {
|
||||
assert.Equal(t, app.Seconds, int64(300), "want app %q to have 5 minutes of usage", app.Slug)
|
||||
} else {
|
||||
assert.Equal(t, app.Seconds, int64(0), "want app %q to have 0 minutes of usage", app.Slug)
|
||||
}
|
||||
}
|
||||
// The full timeframe is <= 24h, so the interval matches exactly.
|
||||
assert.Len(t, resp.IntervalReports, 1, "want one interval report")
|
||||
assert.WithinDuration(t, req.StartTime, resp.IntervalReports[0].StartTime, 0)
|
||||
assert.WithinDuration(t, req.EndTime, resp.IntervalReports[0].EndTime, 0)
|
||||
assert.Equal(t, resp.IntervalReports[0].ActiveUsers, int64(1), "want one active user in the interval report")
|
||||
}
|
||||
|
||||
func TestTemplateInsights_BadRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
y, m, d := time.Now().UTC().Date()
|
||||
today := time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
|
||||
StartTime: today,
|
||||
EndTime: today.AddDate(0, 0, -1),
|
||||
})
|
||||
assert.Error(t, err, "want error for end time before start time")
|
||||
|
||||
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
|
||||
StartTime: today.AddDate(0, 0, -7),
|
||||
EndTime: today.Add(-time.Hour),
|
||||
})
|
||||
assert.Error(t, err, "want error for end time partial day when not today")
|
||||
|
||||
_, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{
|
||||
StartTime: today.AddDate(0, 0, -1),
|
||||
EndTime: today,
|
||||
Interval: "invalid",
|
||||
})
|
||||
assert.Error(t, err, "want error for bad interval")
|
||||
}
|
||||
|
|
|
@ -69,7 +69,7 @@ func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
|
|||
// return ALL workspaces. Not just workspaces the user can view.
|
||||
// nolint:gocritic
|
||||
workspaces, err := api.Database.GetWorkspaces(dbauthz.AsSystemRestricted(ctx), database.GetWorkspacesParams{
|
||||
TemplateIds: []uuid.UUID{template.ID},
|
||||
TemplateIDs: []uuid.UUID{template.ID},
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
|
|
|
@ -0,0 +1,189 @@
|
|||
package codersdk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// Duplicated in coderd.
|
||||
const insightsTimeLayout = time.RFC3339
|
||||
|
||||
// InsightsReportInterval is the interval of time over which to generate a
|
||||
// smaller insights report within a time range.
|
||||
type InsightsReportInterval string
|
||||
|
||||
// InsightsReportInterval enums.
|
||||
const (
|
||||
InsightsReportIntervalDay InsightsReportInterval = "day"
|
||||
)
|
||||
|
||||
// UserLatencyInsightsResponse is the response from the user latency insights
|
||||
// endpoint.
|
||||
type UserLatencyInsightsResponse struct {
|
||||
Report UserLatencyInsightsReport `json:"report"`
|
||||
}
|
||||
|
||||
// UserLatencyInsightsReport is the report from the user latency insights
|
||||
// endpoint.
|
||||
type UserLatencyInsightsReport struct {
|
||||
StartTime time.Time `json:"start_time" format:"date-time"`
|
||||
EndTime time.Time `json:"end_time" format:"date-time"`
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
Users []UserLatency `json:"users"`
|
||||
}
|
||||
|
||||
// UserLatency shows the connection latency for a user.
|
||||
type UserLatency struct {
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
UserID uuid.UUID `json:"user_id" format:"uuid"`
|
||||
Username string `json:"username"`
|
||||
LatencyMS ConnectionLatency `json:"latency_ms"`
|
||||
}
|
||||
|
||||
// ConnectionLatency shows the latency for a connection.
|
||||
type ConnectionLatency struct {
|
||||
P50 float64 `json:"p50" example:"31.312"`
|
||||
P95 float64 `json:"p95" example:"119.832"`
|
||||
}
|
||||
|
||||
type UserLatencyInsightsRequest struct {
|
||||
StartTime time.Time `json:"start_time" format:"date-time"`
|
||||
EndTime time.Time `json:"end_time" format:"date-time"`
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
}
|
||||
|
||||
func (c *Client) UserLatencyInsights(ctx context.Context, req UserLatencyInsightsRequest) (UserLatencyInsightsResponse, error) {
|
||||
var qp []string
|
||||
qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout)))
|
||||
qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout)))
|
||||
if len(req.TemplateIDs) > 0 {
|
||||
var templateIDs []string
|
||||
for _, id := range req.TemplateIDs {
|
||||
templateIDs = append(templateIDs, id.String())
|
||||
}
|
||||
qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ",")))
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("/api/v2/insights/user-latency?%s", strings.Join(qp, "&"))
|
||||
resp, err := c.Request(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return UserLatencyInsightsResponse{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return UserLatencyInsightsResponse{}, ReadBodyAsError(resp)
|
||||
}
|
||||
var result UserLatencyInsightsResponse
|
||||
return result, json.NewDecoder(resp.Body).Decode(&result)
|
||||
}
|
||||
|
||||
// TemplateInsightsResponse is the response from the template insights endpoint.
|
||||
type TemplateInsightsResponse struct {
|
||||
Report TemplateInsightsReport `json:"report"`
|
||||
IntervalReports []TemplateInsightsIntervalReport `json:"interval_reports"`
|
||||
}
|
||||
|
||||
// TemplateInsightsReport is the report from the template insights endpoint.
|
||||
type TemplateInsightsReport struct {
|
||||
StartTime time.Time `json:"start_time" format:"date-time"`
|
||||
EndTime time.Time `json:"end_time" format:"date-time"`
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
ActiveUsers int64 `json:"active_users" example:"22"`
|
||||
AppsUsage []TemplateAppUsage `json:"apps_usage"`
|
||||
// TODO(mafredri): To be introduced in a future pull request.
|
||||
// TemplateParametersUsage []TemplateParameterUsage `json:"parameters_usage"`
|
||||
}
|
||||
|
||||
// TemplateInsightsIntervalReport is the report from the template insights
|
||||
// endpoint for a specific interval.
|
||||
type TemplateInsightsIntervalReport struct {
|
||||
StartTime time.Time `json:"start_time" format:"date-time"`
|
||||
EndTime time.Time `json:"end_time" format:"date-time"`
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
Interval InsightsReportInterval `json:"interval"`
|
||||
ActiveUsers int64 `json:"active_users" example:"14"`
|
||||
}
|
||||
|
||||
// TemplateAppsType defines the type of app reported.
|
||||
type TemplateAppsType string
|
||||
|
||||
// TemplateAppsType enums.
|
||||
const (
|
||||
TemplateAppsTypeBuiltin TemplateAppsType = "builtin"
|
||||
// TODO(mafredri): To be introduced in a future pull request.
|
||||
// TemplateAppsTypeApp TemplateAppsType = "app"
|
||||
)
|
||||
|
||||
// TemplateAppUsage shows the usage of an app for one or more templates.
|
||||
type TemplateAppUsage struct {
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
Type TemplateAppsType `json:"type" example:"builtin"`
|
||||
DisplayName string `json:"display_name" example:"Visual Studio Code"`
|
||||
Slug string `json:"slug" example:"vscode"`
|
||||
Icon string `json:"icon"`
|
||||
Seconds int64 `json:"seconds" example:"80500"`
|
||||
}
|
||||
|
||||
// TODO(mafredri): To be introduced in a future pull request.
|
||||
/*
|
||||
// TemplateParameterUsage shows the usage of a parameter for one or more
|
||||
// templates.
|
||||
type TemplateParameterUsage struct {
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Name string `json:"name"`
|
||||
Values []TemplateParameterValue `json:"values"`
|
||||
}
|
||||
|
||||
// TemplateParameterValue shows the usage of a parameter value for one or more
|
||||
// templates.
|
||||
type TemplateParameterValue struct {
|
||||
Value *string `json:"value"`
|
||||
Icon string `json:"icon"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
*/
|
||||
|
||||
type TemplateInsightsRequest struct {
|
||||
StartTime time.Time `json:"start_time" format:"date-time"`
|
||||
EndTime time.Time `json:"end_time" format:"date-time"`
|
||||
TemplateIDs []uuid.UUID `json:"template_ids" format:"uuid"`
|
||||
Interval InsightsReportInterval `json:"interval"`
|
||||
}
|
||||
|
||||
func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsRequest) (TemplateInsightsResponse, error) {
|
||||
var qp []string
|
||||
qp = append(qp, fmt.Sprintf("start_time=%s", req.StartTime.Format(insightsTimeLayout)))
|
||||
qp = append(qp, fmt.Sprintf("end_time=%s", req.EndTime.Format(insightsTimeLayout)))
|
||||
if len(req.TemplateIDs) > 0 {
|
||||
var templateIDs []string
|
||||
for _, id := range req.TemplateIDs {
|
||||
templateIDs = append(templateIDs, id.String())
|
||||
}
|
||||
qp = append(qp, fmt.Sprintf("template_ids=%s", strings.Join(templateIDs, ",")))
|
||||
}
|
||||
if req.Interval != "" {
|
||||
qp = append(qp, fmt.Sprintf("interval=%s", req.Interval))
|
||||
}
|
||||
|
||||
reqURL := fmt.Sprintf("/api/v2/insights/templates?%s", strings.Join(qp, "&"))
|
||||
resp, err := c.Request(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return TemplateInsightsResponse{}, xerrors.Errorf("make request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return TemplateInsightsResponse{}, ReadBodyAsError(resp)
|
||||
}
|
||||
var result TemplateInsightsResponse
|
||||
return result, json.NewDecoder(resp.Body).Decode(&result)
|
||||
}
|
|
@ -36,3 +36,104 @@ curl -X GET http://coder-server:8080/api/v2/insights/daus \
|
|||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.DAUsResponse](schemas.md#codersdkdausresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get insights about templates
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/insights/templates \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /insights/templates`
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"interval_reports": [
|
||||
{
|
||||
"active_users": 14,
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"interval": "day",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
|
||||
}
|
||||
],
|
||||
"report": {
|
||||
"active_users": 22,
|
||||
"apps_usage": [
|
||||
{
|
||||
"display_name": "Visual Studio Code",
|
||||
"icon": "string",
|
||||
"seconds": 80500,
|
||||
"slug": "vscode",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"type": "builtin"
|
||||
}
|
||||
],
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------- |
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.TemplateInsightsResponse](schemas.md#codersdktemplateinsightsresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get insights about user latency
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/insights/user-latency \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /insights/user-latency`
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"users": [
|
||||
{
|
||||
"latency_ms": {
|
||||
"p50": 31.312,
|
||||
"p95": 119.832
|
||||
},
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------------------- |
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.UserLatencyInsightsResponse](schemas.md#codersdkuserlatencyinsightsresponse) |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
|
|
@ -1281,6 +1281,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
| `autostart` |
|
||||
| `autostop` |
|
||||
|
||||
## codersdk.ConnectionLatency
|
||||
|
||||
```json
|
||||
{
|
||||
"p50": 31.312,
|
||||
"p95": 119.832
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ----- | ------ | -------- | ------------ | ----------- |
|
||||
| `p50` | number | false | | |
|
||||
| `p95` | number | false | | |
|
||||
|
||||
## codersdk.ConvertLoginRequest
|
||||
|
||||
```json
|
||||
|
@ -2895,6 +2911,20 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
| `threshold` | integer | false | | Threshold specifies the number of consecutive failed health checks before returning "unhealthy". |
|
||||
| `url` | string | false | | URL specifies the endpoint to check for the app health. |
|
||||
|
||||
## codersdk.InsightsReportInterval
|
||||
|
||||
```json
|
||||
"day"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value |
|
||||
| ----- |
|
||||
| `day` |
|
||||
|
||||
## codersdk.IssueReconnectingPTYSignedTokenRequest
|
||||
|
||||
```json
|
||||
|
@ -4055,6 +4085,44 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
| ------------- | ----------- |
|
||||
| `provisioner` | `terraform` |
|
||||
|
||||
## codersdk.TemplateAppUsage
|
||||
|
||||
```json
|
||||
{
|
||||
"display_name": "Visual Studio Code",
|
||||
"icon": "string",
|
||||
"seconds": 80500,
|
||||
"slug": "vscode",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"type": "builtin"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------------- | ------------------------------------------------------ | -------- | ------------ | ----------- |
|
||||
| `display_name` | string | false | | |
|
||||
| `icon` | string | false | | |
|
||||
| `seconds` | integer | false | | |
|
||||
| `slug` | string | false | | |
|
||||
| `template_ids` | array of string | false | | |
|
||||
| `type` | [codersdk.TemplateAppsType](#codersdktemplateappstype) | false | | |
|
||||
|
||||
## codersdk.TemplateAppsType
|
||||
|
||||
```json
|
||||
"builtin"
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Value |
|
||||
| --------- |
|
||||
| `builtin` |
|
||||
|
||||
## codersdk.TemplateBuildTimeStats
|
||||
|
||||
```json
|
||||
|
@ -4102,6 +4170,98 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
| `tags` | array of string | false | | |
|
||||
| `url` | string | false | | |
|
||||
|
||||
## codersdk.TemplateInsightsIntervalReport
|
||||
|
||||
```json
|
||||
{
|
||||
"active_users": 14,
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"interval": "day",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------------- | ------------------------------------------------------------------ | -------- | ------------ | ----------- |
|
||||
| `active_users` | integer | false | | |
|
||||
| `end_time` | string | false | | |
|
||||
| `interval` | [codersdk.InsightsReportInterval](#codersdkinsightsreportinterval) | false | | |
|
||||
| `start_time` | string | false | | |
|
||||
| `template_ids` | array of string | false | | |
|
||||
|
||||
## codersdk.TemplateInsightsReport
|
||||
|
||||
```json
|
||||
{
|
||||
"active_users": 22,
|
||||
"apps_usage": [
|
||||
{
|
||||
"display_name": "Visual Studio Code",
|
||||
"icon": "string",
|
||||
"seconds": 80500,
|
||||
"slug": "vscode",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"type": "builtin"
|
||||
}
|
||||
],
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------------- | --------------------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `active_users` | integer | false | | |
|
||||
| `apps_usage` | array of [codersdk.TemplateAppUsage](#codersdktemplateappusage) | false | | |
|
||||
| `end_time` | string | false | | |
|
||||
| `start_time` | string | false | | |
|
||||
| `template_ids` | array of string | false | | |
|
||||
|
||||
## codersdk.TemplateInsightsResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"interval_reports": [
|
||||
{
|
||||
"active_users": 14,
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"interval": "day",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
|
||||
}
|
||||
],
|
||||
"report": {
|
||||
"active_users": 22,
|
||||
"apps_usage": [
|
||||
{
|
||||
"display_name": "Visual Studio Code",
|
||||
"icon": "string",
|
||||
"seconds": 80500,
|
||||
"slug": "vscode",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"type": "builtin"
|
||||
}
|
||||
],
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `interval_reports` | array of [codersdk.TemplateInsightsIntervalReport](#codersdktemplateinsightsintervalreport) | false | | |
|
||||
| `report` | [codersdk.TemplateInsightsReport](#codersdktemplateinsightsreport) | false | | |
|
||||
|
||||
## codersdk.TemplateRestartRequirement
|
||||
|
||||
```json
|
||||
|
@ -4694,6 +4854,88 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|
|||
| `status` | `active` |
|
||||
| `status` | `suspended` |
|
||||
|
||||
## codersdk.UserLatency
|
||||
|
||||
```json
|
||||
{
|
||||
"latency_ms": {
|
||||
"p50": 31.312,
|
||||
"p95": 119.832
|
||||
},
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------------- | -------------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `latency_ms` | [codersdk.ConnectionLatency](#codersdkconnectionlatency) | false | | |
|
||||
| `template_ids` | array of string | false | | |
|
||||
| `user_id` | string | false | | |
|
||||
| `username` | string | false | | |
|
||||
|
||||
## codersdk.UserLatencyInsightsReport
|
||||
|
||||
```json
|
||||
{
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"users": [
|
||||
{
|
||||
"latency_ms": {
|
||||
"p50": 31.312,
|
||||
"p95": 119.832
|
||||
},
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------------- | ----------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `end_time` | string | false | | |
|
||||
| `start_time` | string | false | | |
|
||||
| `template_ids` | array of string | false | | |
|
||||
| `users` | array of [codersdk.UserLatency](#codersdkuserlatency) | false | | |
|
||||
|
||||
## codersdk.UserLatencyInsightsResponse
|
||||
|
||||
```json
|
||||
{
|
||||
"report": {
|
||||
"end_time": "2019-08-24T14:15:22Z",
|
||||
"start_time": "2019-08-24T14:15:22Z",
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"users": [
|
||||
{
|
||||
"latency_ms": {
|
||||
"p50": 31.312,
|
||||
"p95": 119.832
|
||||
},
|
||||
"template_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"user_id": "a169451c-8525-4352-b8ca-070dd449a1a5",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------- | ------------------------------------------------------------------------ | -------- | ------------ | ----------- |
|
||||
| `report` | [codersdk.UserLatencyInsightsReport](#codersdkuserlatencyinsightsreport) | false | | |
|
||||
|
||||
## codersdk.UserLoginType
|
||||
|
||||
```json
|
||||
|
|
|
@ -140,6 +140,12 @@ export interface BuildInfoResponse {
|
|||
readonly workspace_proxy: boolean
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface ConnectionLatency {
|
||||
readonly p50: number
|
||||
readonly p95: number
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
export interface ConvertLoginRequest {
|
||||
readonly to_type: LoginType
|
||||
|
@ -886,6 +892,16 @@ export interface TemplateACL {
|
|||
readonly group: TemplateGroup[]
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface TemplateAppUsage {
|
||||
readonly template_ids: string[]
|
||||
readonly type: TemplateAppsType
|
||||
readonly display_name: string
|
||||
readonly slug: string
|
||||
readonly icon: string
|
||||
readonly seconds: number
|
||||
}
|
||||
|
||||
// From codersdk/templates.go
|
||||
export type TemplateBuildTimeStats = Record<
|
||||
WorkspaceTransition,
|
||||
|
@ -908,6 +924,38 @@ export interface TemplateGroup extends Group {
|
|||
readonly role: TemplateRole
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface TemplateInsightsIntervalReport {
|
||||
readonly start_time: string
|
||||
readonly end_time: string
|
||||
readonly template_ids: string[]
|
||||
readonly interval: InsightsReportInterval
|
||||
readonly active_users: number
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface TemplateInsightsReport {
|
||||
readonly start_time: string
|
||||
readonly end_time: string
|
||||
readonly template_ids: string[]
|
||||
readonly active_users: number
|
||||
readonly apps_usage: TemplateAppUsage[]
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface TemplateInsightsRequest {
|
||||
readonly start_time: string
|
||||
readonly end_time: string
|
||||
readonly template_ids: string[]
|
||||
readonly interval: InsightsReportInterval
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface TemplateInsightsResponse {
|
||||
readonly report: TemplateInsightsReport
|
||||
readonly interval_reports: TemplateInsightsIntervalReport[]
|
||||
}
|
||||
|
||||
// From codersdk/templates.go
|
||||
export interface TemplateRestartRequirement {
|
||||
readonly days_of_week: string[]
|
||||
|
@ -1116,6 +1164,34 @@ export interface User {
|
|||
readonly avatar_url: string
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface UserLatency {
|
||||
readonly template_ids: string[]
|
||||
readonly user_id: string
|
||||
readonly username: string
|
||||
readonly latency_ms: ConnectionLatency
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface UserLatencyInsightsReport {
|
||||
readonly start_time: string
|
||||
readonly end_time: string
|
||||
readonly template_ids: string[]
|
||||
readonly users: UserLatency[]
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface UserLatencyInsightsRequest {
|
||||
readonly start_time: string
|
||||
readonly end_time: string
|
||||
readonly template_ids: string[]
|
||||
}
|
||||
|
||||
// From codersdk/insights.go
|
||||
export interface UserLatencyInsightsResponse {
|
||||
readonly report: UserLatencyInsightsReport
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
export interface UserLoginType {
|
||||
readonly login_type: LoginType
|
||||
|
@ -1513,6 +1589,10 @@ export const GitProviders: GitProvider[] = [
|
|||
"gitlab",
|
||||
]
|
||||
|
||||
// From codersdk/insights.go
|
||||
export type InsightsReportInterval = "day"
|
||||
export const InsightsReportIntervals: InsightsReportInterval[] = ["day"]
|
||||
|
||||
// From codersdk/provisionerdaemons.go
|
||||
export type JobErrorCode =
|
||||
| "MISSING_TEMPLATE_PARAMETER"
|
||||
|
@ -1664,6 +1744,10 @@ export const ServerSentEventTypes: ServerSentEventType[] = [
|
|||
"ping",
|
||||
]
|
||||
|
||||
// From codersdk/insights.go
|
||||
export type TemplateAppsType = "builtin"
|
||||
export const TemplateAppsTypes: TemplateAppsType[] = ["builtin"]
|
||||
|
||||
// From codersdk/templates.go
|
||||
export type TemplateRole = "" | "admin" | "use"
|
||||
export const TemplateRoles: TemplateRole[] = ["", "admin", "use"]
|
||||
|
|
Loading…
Reference in New Issue