diff --git a/coderd/insights.go b/coderd/insights.go index a0113b15c1..714835db43 100644 --- a/coderd/insights.go +++ b/coderd/insights.go @@ -257,6 +257,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { endTimeString = p.String(vals, "", "end_time") intervalString = p.String(vals, "", "interval") templateIDs = p.UUIDs(vals, []uuid.UUID{}, "template_ids") + sectionStrings = p.Strings(vals, templateInsightsSectionAsStrings(codersdk.TemplateInsightsSectionIntervalReports, codersdk.TemplateInsightsSectionReport), "sections") ) p.ErrorExcessParams(vals) if len(p.Errors) > 0 { @@ -275,6 +276,10 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { if !ok { return } + sections, ok := parseTemplateInsightsSections(ctx, rw, sectionStrings) + if !ok { + return + } var usage database.GetTemplateInsightsRow var appUsage []database.GetTemplateAppInsightsRow @@ -289,7 +294,7 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { // overhead from a transaction is not worth it. eg.Go(func() error { var err error - if interval != "" { + if interval != "" && slices.Contains(sections, codersdk.TemplateInsightsSectionIntervalReports) { dailyUsage, err = api.Database.GetTemplateInsightsByInterval(egCtx, database.GetTemplateInsightsByIntervalParams{ StartTime: startTime, EndTime: endTime, @@ -303,6 +308,10 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return nil }) eg.Go(func() error { + if !slices.Contains(sections, codersdk.TemplateInsightsSectionReport) { + return nil + } + var err error usage, err = api.Database.GetTemplateInsights(egCtx, database.GetTemplateInsightsParams{ StartTime: startTime, @@ -315,6 +324,10 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { return nil }) eg.Go(func() error { + if !slices.Contains(sections, codersdk.TemplateInsightsSectionReport) { + return nil + } + var err error appUsage, err = api.Database.GetTemplateAppInsights(egCtx, database.GetTemplateAppInsightsParams{ StartTime: startTime, @@ -330,6 +343,10 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { // Template parameter insights have no risk of inconsistency with the other // insights. eg.Go(func() error { + if !slices.Contains(sections, codersdk.TemplateInsightsSectionReport) { + return nil + } + var err error parameterRows, err = api.Database.GetTemplateParameterInsights(ctx, database.GetTemplateParameterInsightsParams{ StartTime: startTime, @@ -365,16 +382,20 @@ func (api *API) insightsTemplates(rw http.ResponseWriter, r *http.Request) { } resp := codersdk.TemplateInsightsResponse{ - Report: codersdk.TemplateInsightsReport{ + IntervalReports: []codersdk.TemplateInsightsIntervalReport{}, + } + + if slices.Contains(sections, codersdk.TemplateInsightsSectionReport) { + resp.Report = &codersdk.TemplateInsightsReport{ StartTime: startTime, EndTime: endTime, TemplateIDs: convertTemplateInsightsTemplateIDs(usage, appUsage), ActiveUsers: convertTemplateInsightsActiveUsers(usage, appUsage), AppsUsage: convertTemplateInsightsApps(usage, appUsage), ParametersUsage: parametersUsage, - }, - IntervalReports: []codersdk.TemplateInsightsIntervalReport{}, + } } + for _, row := range dailyUsage { resp.IntervalReports = append(resp.IntervalReports, codersdk.TemplateInsightsIntervalReport{ // NOTE(mafredri): This might not be accurate over DST since the @@ -654,3 +675,33 @@ func lastReportIntervalHasAtLeastSixDays(startTime, endTime time.Time) bool { // when the duration can be shorter than 6 days: 5 days 23 hours. return lastReportIntervalDays >= 6*24*time.Hour || startTime.AddDate(0, 0, 6).Equal(endTime) } + +func templateInsightsSectionAsStrings(sections ...codersdk.TemplateInsightsSection) []string { + t := make([]string, len(sections)) + for i, s := range sections { + t[i] = string(s) + } + return t +} + +func parseTemplateInsightsSections(ctx context.Context, rw http.ResponseWriter, sections []string) ([]codersdk.TemplateInsightsSection, bool) { + t := make([]codersdk.TemplateInsightsSection, len(sections)) + for i, s := range sections { + switch v := codersdk.TemplateInsightsSection(s); v { + case codersdk.TemplateInsightsSectionIntervalReports, codersdk.TemplateInsightsSectionReport: + t[i] = v + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Query parameter has invalid value.", + Validations: []codersdk.ValidationError{ + { + Field: "sections", + Detail: fmt.Sprintf("must be one of %v", []codersdk.TemplateInsightsSection{codersdk.TemplateInsightsSectionIntervalReports, codersdk.TemplateInsightsSectionReport}), + }, + }, + }) + return nil, false + } + } + return t, true +} diff --git a/coderd/insights_test.go b/coderd/insights_test.go index 49b7af0841..6f1e365a9a 100644 --- a/coderd/insights_test.go +++ b/coderd/insights_test.go @@ -1135,6 +1135,30 @@ func TestTemplateInsights_Golden(t *testing.T) { } }, }, + { + name: "three weeks second template only report", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[1].id}, + StartTime: frozenWeekAgo.AddDate(0, 0, -14), + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalWeek, + Sections: []codersdk.TemplateInsightsSection{codersdk.TemplateInsightsSectionReport}, + } + }, + }, + { + name: "three weeks second template only interval reports", + makeRequest: func(templates []*testTemplate) codersdk.TemplateInsightsRequest { + return codersdk.TemplateInsightsRequest{ + TemplateIDs: []uuid.UUID{templates[1].id}, + StartTime: frozenWeekAgo.AddDate(0, 0, -14), + EndTime: frozenWeekAgo.AddDate(0, 0, 7), + Interval: codersdk.InsightsReportIntervalWeek, + Sections: []codersdk.TemplateInsightsSection{codersdk.TemplateInsightsSectionIntervalReports}, + } + }, + }, }, }, { @@ -2049,6 +2073,14 @@ func TestTemplateInsights_BadRequest(t *testing.T) { Interval: codersdk.InsightsReportIntervalWeek, }) assert.Error(t, err, "last report interval must have at least 6 days") + + _, err = client.TemplateInsights(ctx, codersdk.TemplateInsightsRequest{ + StartTime: today.AddDate(0, 0, -1), + EndTime: today, + Interval: codersdk.InsightsReportIntervalWeek, + Sections: []codersdk.TemplateInsightsSection{"invalid"}, + }) + assert.Error(t, err, "want error for bad section") } func TestTemplateInsights_RBAC(t *testing.T) { diff --git a/coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template_only_interval_reports.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template_only_interval_reports.json.golden new file mode 100644 index 0000000000..34ffaeccf6 --- /dev/null +++ b/coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template_only_interval_reports.json.golden @@ -0,0 +1,29 @@ +{ + "interval_reports": [ + { + "start_time": "2023-08-01T00:00:00Z", + "end_time": "2023-08-08T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "interval": "week", + "active_users": 1 + }, + { + "start_time": "2023-08-08T00:00:00Z", + "end_time": "2023-08-15T00:00:00Z", + "template_ids": [], + "interval": "week", + "active_users": 0 + }, + { + "start_time": "2023-08-15T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "interval": "week", + "active_users": 1 + } + ] +} diff --git a/coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template_only_report.json.golden b/coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template_only_report.json.golden new file mode 100644 index 0000000000..e3a1a2cd39 --- /dev/null +++ b/coderd/testdata/insights/template/multiple_users_and_workspaces_three_weeks_second_template_only_report.json.golden @@ -0,0 +1,63 @@ +{ + "report": { + "start_time": "2023-08-01T00:00:00Z", + "end_time": "2023-08-22T00:00:00Z", + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "active_users": 1, + "apps_usage": [ + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "Visual Studio Code", + "slug": "vscode", + "icon": "/icon/code.svg", + "seconds": 3600 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "JetBrains", + "slug": "jetbrains", + "icon": "/icon/intellij.svg", + "seconds": 0 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "Web Terminal", + "slug": "reconnecting-pty", + "icon": "/icon/terminal.svg", + "seconds": 7200 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "builtin", + "display_name": "SSH", + "slug": "ssh", + "icon": "/icon/terminal.svg", + "seconds": 10800 + }, + { + "template_ids": [ + "00000000-0000-0000-0000-000000000002" + ], + "type": "app", + "display_name": "app1", + "slug": "app1", + "icon": "/icon1.png", + "seconds": 21600 + } + ], + "parameters_usage": [] + } +} diff --git a/coderd/testdata/insights/template/parameters_two_days_ago,_no_data.json.golden b/coderd/testdata/insights/template/parameters_two_days_ago,_no_data.json.golden index e3875b2a34..f735c19be4 100644 --- a/coderd/testdata/insights/template/parameters_two_days_ago,_no_data.json.golden +++ b/coderd/testdata/insights/template/parameters_two_days_ago,_no_data.json.golden @@ -39,6 +39,5 @@ } ], "parameters_usage": [] - }, - "interval_reports": [] + } } diff --git a/coderd/testdata/insights/template/parameters_yesterday_and_today_deployment_wide.json.golden b/coderd/testdata/insights/template/parameters_yesterday_and_today_deployment_wide.json.golden index fc7ccd8a50..f260941893 100644 --- a/coderd/testdata/insights/template/parameters_yesterday_and_today_deployment_wide.json.golden +++ b/coderd/testdata/insights/template/parameters_yesterday_and_today_deployment_wide.json.golden @@ -160,6 +160,5 @@ ] } ] - }, - "interval_reports": [] + } } diff --git a/codersdk/insights.go b/codersdk/insights.go index 6312c6f2c4..047f35a4da 100644 --- a/codersdk/insights.go +++ b/codersdk/insights.go @@ -38,6 +38,15 @@ const ( InsightsReportIntervalWeek InsightsReportInterval = "week" ) +// TemplateInsightsSection defines the section to be included in the template insights response. +type TemplateInsightsSection string + +// TemplateInsightsSection enums. +const ( + TemplateInsightsSectionIntervalReports TemplateInsightsSection = "interval_reports" + TemplateInsightsSectionReport TemplateInsightsSection = "report" +) + // UserLatencyInsightsResponse is the response from the user latency insights // endpoint. type UserLatencyInsightsResponse struct { @@ -158,8 +167,8 @@ func (c *Client) UserActivityInsights(ctx context.Context, req UserActivityInsig // TemplateInsightsResponse is the response from the template insights endpoint. type TemplateInsightsResponse struct { - Report TemplateInsightsReport `json:"report"` - IntervalReports []TemplateInsightsIntervalReport `json:"interval_reports"` + Report *TemplateInsightsReport `json:"report,omitempty"` + IntervalReports []TemplateInsightsIntervalReport `json:"interval_reports,omitempty"` } // TemplateInsightsReport is the report from the template insights endpoint. @@ -221,10 +230,11 @@ type TemplateParameterValue struct { } 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" example:"day"` + 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" example:"day"` + Sections []TemplateInsightsSection `json:"sections" example:"report"` } func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsRequest) (TemplateInsightsResponse, error) { @@ -241,6 +251,13 @@ func (c *Client) TemplateInsights(ctx context.Context, req TemplateInsightsReque if req.Interval != "" { qp.Add("interval", string(req.Interval)) } + if len(req.Sections) > 0 { + var sections []string + for _, sec := range req.Sections { + sections = append(sections, string(sec)) + } + qp.Add("sections", strings.Join(sections, ",")) + } reqURL := fmt.Sprintf("/api/v2/insights/templates?%s", qp.Encode()) resp, err := c.Request(ctx, http.MethodGet, reqURL, nil) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0b7f0c4cde..3acced5ba7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -964,12 +964,13 @@ export interface TemplateInsightsRequest { readonly end_time: string; readonly template_ids: string[]; readonly interval: InsightsReportInterval; + readonly sections: TemplateInsightsSection[]; } // From codersdk/insights.go export interface TemplateInsightsResponse { - readonly report: TemplateInsightsReport; - readonly interval_reports: TemplateInsightsIntervalReport[]; + readonly report?: TemplateInsightsReport; + readonly interval_reports?: TemplateInsightsIntervalReport[]; } // From codersdk/insights.go @@ -1877,6 +1878,13 @@ export const ServerSentEventTypes: ServerSentEventType[] = [ export type TemplateAppsType = "app" | "builtin"; export const TemplateAppsTypes: TemplateAppsType[] = ["app", "builtin"]; +// From codersdk/insights.go +export type TemplateInsightsSection = "interval_reports" | "report"; +export const TemplateInsightsSections: TemplateInsightsSection[] = [ + "interval_reports", + "report", +]; + // From codersdk/templates.go export type TemplateRole = "" | "admin" | "use"; export const TemplateRoles: TemplateRole[] = ["", "admin", "use"]; diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index e6ae5e642e..d9c1444af4 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -20,6 +20,7 @@ import { getTemplatePageTitle } from "../utils"; import { Loader } from "components/Loader/Loader"; import { DAUsResponse, + TemplateAppUsage, TemplateInsightsResponse, TemplateParameterUsage, TemplateParameterValue, @@ -105,11 +106,11 @@ export const TemplateInsightsPageView = ({ @@ -202,7 +203,7 @@ const TemplateUsagePanel = ({ data, ...panelProps }: PanelProps & { - data: TemplateInsightsResponse["report"]["apps_usage"] | undefined; + data: TemplateAppUsage[] | undefined; }) => { const validUsage = data?.filter((u) => u.seconds > 0); const totalInSeconds = @@ -293,7 +294,7 @@ const TemplateParametersUsagePanel = ({ data, ...panelProps }: PanelProps & { - data: TemplateInsightsResponse["report"]["parameters_usage"] | undefined; + data: TemplateParameterUsage[] | undefined; }) => { return ( @@ -588,12 +589,14 @@ function mapToDAUsResponse( ): DAUsResponse { return { tz_hour_offset: 0, - entries: data.map((d) => { - return { - amount: d.active_users, - date: d.start_time, - }; - }), + entries: data + ? data.map((d) => { + return { + amount: d.active_users, + date: d.start_time, + }; + }) + : [], }; }