diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index f6cff3d136c00..3882381d99b40 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -7838,7 +7838,8 @@ const docTemplate = `{
"tailnet_ha_coordinator",
"convert-to-oidc",
"single_tailnet",
- "template_restart_requirement"
+ "template_restart_requirement",
+ "template_insights_page"
],
"x-enum-varnames": [
"ExperimentMoons",
@@ -7846,7 +7847,8 @@ const docTemplate = `{
"ExperimentTailnetHACoordinator",
"ExperimentConvertToOIDC",
"ExperimentSingleTailnet",
- "ExperimentTemplateRestartRequirement"
+ "ExperimentTemplateRestartRequirement",
+ "ExperimentTemplateInsightsPage"
]
},
"codersdk.Feature": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index d3bc648e764ec..ab906e6c7e8dc 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -7015,7 +7015,8 @@
"tailnet_ha_coordinator",
"convert-to-oidc",
"single_tailnet",
- "template_restart_requirement"
+ "template_restart_requirement",
+ "template_insights_page"
],
"x-enum-varnames": [
"ExperimentMoons",
@@ -7023,7 +7024,8 @@
"ExperimentTailnetHACoordinator",
"ExperimentConvertToOIDC",
"ExperimentSingleTailnet",
- "ExperimentTemplateRestartRequirement"
+ "ExperimentTemplateRestartRequirement",
+ "ExperimentTemplateInsightsPage"
]
},
"codersdk.Feature": {
diff --git a/codersdk/deployment.go b/codersdk/deployment.go
index 9e6acc9d91580..ff48659b22dad 100644
--- a/codersdk/deployment.go
+++ b/codersdk/deployment.go
@@ -1854,6 +1854,9 @@ const (
// quiet hours instead of max_ttl.
ExperimentTemplateRestartRequirement Experiment = "template_restart_requirement"
+ // Insights page
+ ExperimentTemplateInsightsPage Experiment = "template_insights_page"
+
// Add new experiments here!
// ExperimentExample Experiment = "example"
)
@@ -1862,7 +1865,9 @@ const (
// users to opt-in to via --experimental='*'.
// Experiments that are not ready for consumption by all users should
// not be included here and will be essentially hidden.
-var ExperimentsAll = Experiments{}
+var ExperimentsAll = Experiments{
+ ExperimentTemplateInsightsPage,
+}
// Experiments is a list of experiments that are enabled for the deployment.
// Multiple experiments may be enabled at the same time.
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index d01275456b639..d2b679e7e33d5 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -2610,6 +2610,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `convert-to-oidc` |
| `single_tailnet` |
| `template_restart_requirement` |
+| `template_insights_page` |
## codersdk.Feature
diff --git a/site/package.json b/site/package.json
index 25ff8959de6cf..6a8e6ae153658 100644
--- a/site/package.json
+++ b/site/package.json
@@ -56,6 +56,7 @@
"canvas": "2.11.0",
"chart.js": "3.9.1",
"chartjs-adapter-date-fns": "3.0.0",
+ "chroma-js": "2.4.2",
"color-convert": "2.0.1",
"cron-parser": "4.7.0",
"cronstrue": "2.28.0",
@@ -116,6 +117,7 @@
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
+ "@types/chroma-js": "2.4.0",
"@types/jest": "29.5.2",
"@types/node": "14.18.22",
"@types/react": "18.2.6",
diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx
index 0b31a887371c3..34cbc0ed6d246 100644
--- a/site/src/AppRouter.tsx
+++ b/site/src/AppRouter.tsx
@@ -179,6 +179,10 @@ const AddNewLicensePage = lazy(
const TemplateEmbedPage = lazy(
() => import("./pages/TemplatePage/TemplateEmbedPage/TemplateEmbedPage"),
)
+const TemplateInsightsPage = lazy(
+ () =>
+ import("./pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage"),
+)
export const AppRouter: FC = () => {
return (
@@ -212,6 +216,7 @@ export const AppRouter: FC = () => {
} />
} />
} />
+ } />
} />
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index ba412e39e8764..8aa392b49febb 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -1368,3 +1368,28 @@ export const getWorkspaceParameters = async (workspace: TypesGen.Workspace) => {
buildParameters,
}
}
+
+type InsightsFilter = {
+ start_time: string
+ end_time: string
+ template_ids: string
+}
+
+export const getInsightsUserLatency = async (
+ filters: InsightsFilter,
+): Promise => {
+ const params = new URLSearchParams(filters)
+ const response = await axios.get(`/api/v2/insights/user-latency?${params}`)
+ return response.data
+}
+
+export const getInsightsTemplate = async (
+ filters: InsightsFilter,
+): Promise => {
+ const params = new URLSearchParams({
+ ...filters,
+ interval: "day",
+ })
+ const response = await axios.get(`/api/v2/insights/templates?${params}`)
+ return response.data
+}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index d5f60f4b19ed9..7571aa0f6289c 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1555,6 +1555,7 @@ export type Experiment =
| "moons"
| "single_tailnet"
| "tailnet_ha_coordinator"
+ | "template_insights_page"
| "template_restart_requirement"
| "workspace_actions"
export const Experiments: Experiment[] = [
@@ -1562,6 +1563,7 @@ export const Experiments: Experiment[] = [
"moons",
"single_tailnet",
"tailnet_ha_coordinator",
+ "template_insights_page",
"template_restart_requirement",
"workspace_actions",
]
diff --git a/site/src/components/DAUChart/DAUChart.test.tsx b/site/src/components/DAUChart/DAUChart.test.tsx
deleted file mode 100644
index 6a6866a5d095d..0000000000000
--- a/site/src/components/DAUChart/DAUChart.test.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { render } from "testHelpers/renderHelpers"
-import { DAUChart, Language } from "./DAUChart"
-
-import { screen } from "@testing-library/react"
-import { ResizeObserver } from "resize-observer"
-
-// The Chart performs dynamic resizes which fail in tests without this.
-Object.defineProperty(window, "ResizeObserver", {
- value: ResizeObserver,
-})
-
-describe("DAUChart", () => {
- it("renders a helpful paragraph on empty state", async () => {
- render(
- ,
- )
-
- await screen.findAllByText(Language.loadingText)
- })
- it("renders a graph", async () => {
- render(
- ,
- )
-
- await screen.findAllByText(Language.chartTitle)
- })
-})
diff --git a/site/src/components/DAUChart/DAUChart.tsx b/site/src/components/DAUChart/DAUChart.tsx
index 2b9c508b2060b..c08cf5bc1db2f 100644
--- a/site/src/components/DAUChart/DAUChart.tsx
+++ b/site/src/components/DAUChart/DAUChart.tsx
@@ -1,3 +1,4 @@
+import Box from "@mui/material/Box"
import { Theme } from "@mui/material/styles"
import useTheme from "@mui/styles/useTheme"
import * as TypesGen from "api/typesGenerated"
@@ -15,13 +16,11 @@ import {
Tooltip,
} from "chart.js"
import "chartjs-adapter-date-fns"
-import { Stack } from "components/Stack/Stack"
import {
HelpTooltip,
- HelpTooltipText,
HelpTooltipTitle,
+ HelpTooltipText,
} from "components/Tooltips/HelpTooltip"
-import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection"
import dayjs from "dayjs"
import { FC } from "react"
import { Line } from "react-chartjs-2"
@@ -40,24 +39,10 @@ ChartJS.register(
export interface DAUChartProps {
daus: TypesGen.DAUsResponse
}
-export const Language = {
- loadingText: "DAU stats are loading. Check back later.",
- chartTitle: "Daily Active Users",
-}
export const DAUChart: FC = ({ daus }) => {
const theme: Theme = useTheme()
- if (daus.entries.length === 0) {
- return (
- // We generate hidden element to prove this path is taken in the test
- // and through site inspection.
-
-
{Language.loadingText}
-
- )
- }
-
const labels = daus.entries.map((val) => {
return dayjs(val.date).format("YYYY-MM-DD")
})
@@ -92,42 +77,39 @@ export const DAUChart: FC = ({ daus }) => {
},
},
},
- aspectRatio: 10 / 1,
+ maintainAspectRatio: false,
}
return (
- <>
-
- {Language.chartTitle}
-
- How do we calculate DAUs?
-
- We use all workspace connection traffic to calculate DAUs.
-
-
-
- }
- >
-
-
- >
+
+ )
+}
+
+export const DAUTitle = () => {
+ return (
+
+ Daily Active Users
+
+ How do we calculate DAUs?
+
+ We use all workspace connection traffic to calculate DAUs.
+
+
+
)
}
diff --git a/site/src/components/Loader/Loader.tsx b/site/src/components/Loader/Loader.tsx
index 39afe9e885469..65cc9d474da1a 100644
--- a/site/src/components/Loader/Loader.tsx
+++ b/site/src/components/Loader/Loader.tsx
@@ -1,9 +1,10 @@
-import Box from "@mui/material/Box"
+import Box, { BoxProps } from "@mui/material/Box"
import CircularProgress from "@mui/material/CircularProgress"
import { FC } from "react"
-export const Loader: FC> = ({
+export const Loader: FC<{ size?: number } & BoxProps> = ({
size = 26,
+ ...boxProps
}) => {
return (
> = ({
alignItems="center"
justifyContent="center"
data-testid="loader"
+ {...boxProps}
>
diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx
index 9247c21d77ded..4cddee463c591 100644
--- a/site/src/components/TemplateLayout/TemplateLayout.tsx
+++ b/site/src/components/TemplateLayout/TemplateLayout.tsx
@@ -15,6 +15,7 @@ import {
import { useQuery } from "@tanstack/react-query"
import { AuthorizationRequest } from "api/typesGenerated"
import { ErrorAlert } from "components/Alert/ErrorAlert"
+import { useDashboard } from "components/Dashboard/DashboardProvider"
const templatePermissions = (
templateId: string,
@@ -71,6 +72,12 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
queryKey: ["template", templateName],
queryFn: () => fetchTemplate(orgId, templateName),
})
+ const dashboard = useDashboard()
+ const hasInsightsEnabled =
+ dashboard.experiments.includes("template_insights_page") ||
+ process.env.NODE_ENV === "development"
+ const shouldShowInsights =
+ hasInsightsEnabled && data?.permissions?.canUpdateTemplate
if (error) {
return (
@@ -157,6 +164,19 @@ export const TemplateLayout: FC<{ children?: JSX.Element }> = ({
>
Embed
+ {shouldShowInsights && (
+
+ combineClasses([
+ styles.tabItem,
+ isActive ? styles.tabItemActive : undefined,
+ ])
+ }
+ >
+ Insights
+
+ )}
diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx
index 7bc000e96d84b..8e9df03477f0f 100644
--- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx
+++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx
@@ -1,10 +1,12 @@
+import Box from "@mui/material/Box"
import { DeploymentOption } from "api/types"
import { DAUsResponse } from "api/typesGenerated"
import { ErrorAlert } from "components/Alert/ErrorAlert"
-import { DAUChart } from "components/DAUChart/DAUChart"
+import { DAUChart, DAUTitle } from "components/DAUChart/DAUChart"
import { Header } from "components/DeploySettingsLayout/Header"
import OptionsTable from "components/DeploySettingsLayout/OptionsTable"
import { Stack } from "components/Stack/Stack"
+import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection"
import { useDeploymentOptions } from "utils/deployOptions"
import { docs } from "utils/docs"
@@ -29,7 +31,13 @@ export const GeneralSettingsPageView = ({
{Boolean(getDeploymentDAUsError) && (
)}
- {deploymentDAUs && }
+ {deploymentDAUs && (
+
+ }>
+
+
+
+ )}
= {
+ title: "pages/TemplateInsightsPageView",
+ component: TemplateInsightsPageView,
+}
+
+export default meta
+type Story = StoryObj
+
+export const Loading: Story = {
+ args: {
+ templateInsights: undefined,
+ userLatency: undefined,
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ templateInsights: {
+ interval_reports: [],
+ report: {
+ active_users: 0,
+ end_time: "",
+ start_time: "",
+ template_ids: [],
+ apps_usage: [],
+ },
+ },
+ userLatency: {
+ report: {
+ end_time: "",
+ start_time: "",
+ template_ids: [],
+ users: [],
+ },
+ },
+ },
+}
+
+export const Loaded: Story = {
+ args: {
+ // Got from dev.coder.com network calls
+ templateInsights: {
+ report: {
+ start_time: "2023-07-18T00:00:00Z",
+ end_time: "2023-07-25T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ active_users: 14,
+ apps_usage: [
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ type: "builtin",
+ display_name: "Visual Studio Code",
+ slug: "vscode",
+ icon: "/icon/code.svg",
+ seconds: 2513400,
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ type: "builtin",
+ display_name: "JetBrains",
+ slug: "jetbrains",
+ icon: "/icon/intellij.svg",
+ seconds: 0,
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ type: "builtin",
+ display_name: "Web Terminal",
+ slug: "reconnecting-pty",
+ icon: "/icon/terminal.svg",
+ seconds: 110400,
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ type: "builtin",
+ display_name: "SSH",
+ slug: "ssh",
+ icon: "/icon/terminal.svg",
+ seconds: 1020900,
+ },
+ ],
+ },
+ interval_reports: [
+ {
+ start_time: "2023-07-18T00:00:00Z",
+ end_time: "2023-07-19T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ interval: "day",
+ active_users: 13,
+ },
+ {
+ start_time: "2023-07-19T00:00:00Z",
+ end_time: "2023-07-20T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ interval: "day",
+ active_users: 11,
+ },
+ {
+ start_time: "2023-07-20T00:00:00Z",
+ end_time: "2023-07-21T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ interval: "day",
+ active_users: 11,
+ },
+ {
+ start_time: "2023-07-21T00:00:00Z",
+ end_time: "2023-07-22T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ interval: "day",
+ active_users: 13,
+ },
+ {
+ start_time: "2023-07-22T00:00:00Z",
+ end_time: "2023-07-23T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ interval: "day",
+ active_users: 7,
+ },
+ {
+ start_time: "2023-07-23T00:00:00Z",
+ end_time: "2023-07-24T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ interval: "day",
+ active_users: 5,
+ },
+ {
+ start_time: "2023-07-24T00:00:00Z",
+ end_time: "2023-07-25T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ interval: "day",
+ active_users: 11,
+ },
+ ],
+ },
+ userLatency: {
+ report: {
+ start_time: "2023-07-18T00:00:00Z",
+ end_time: "2023-07-25T00:00:00Z",
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ users: [
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "0bac0dfd-b086-4b6d-b8ba-789e0eca7451",
+ username: "kylecarbs",
+ avatar_url: "https://avatars.githubusercontent.com/u/7122116?v=4",
+ latency_ms: {
+ p50: 63.826,
+ p95: 139.328,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "12b03f43-1bb7-4fca-967a-585c97f31682",
+ username: "coadler",
+ avatar_url: "https://avatars.githubusercontent.com/u/6332295?v=4",
+ latency_ms: {
+ p50: 51.0745,
+ p95: 54.62562499999999,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "15890ddb-142c-443d-8fd5-cd8307256ab1",
+ username: "jsjoeio",
+ avatar_url: "https://avatars.githubusercontent.com/u/3806031?v=4",
+ latency_ms: {
+ p50: 37.444,
+ p95: 37.8488,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "3f8c0eef-6a45-4759-a4d6-d00bbffb1369",
+ username: "dean",
+ avatar_url: "https://avatars.githubusercontent.com/u/11241812?v=4",
+ latency_ms: {
+ p50: 7.1295,
+ p95: 70.34084999999999,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "59da0bfe-9c99-47fa-a563-f9fdb18449d0",
+ username: "cian",
+ avatar_url:
+ "https://lh3.googleusercontent.com/a/AAcHTtdsYrtIfkXU52rHXhY9DHehpw-slUKe9v6UELLJgXT2mDM=s96-c",
+ latency_ms: {
+ p50: 42.14975,
+ p95: 125.5441,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "5ccd3128-cbbb-4cfb-8139-5a1edbb60c71",
+ username: "bpmct",
+ avatar_url: "https://avatars.githubusercontent.com/u/22407953?v=4",
+ latency_ms: {
+ p50: 42.175,
+ p95: 43.437599999999996,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "631f78f6-098e-4cb0-ae4f-418fafb0a406",
+ username: "matifali",
+ avatar_url: "https://avatars.githubusercontent.com/u/10648092?v=4",
+ latency_ms: {
+ p50: 78.02,
+ p95: 86.3328,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "740bba7f-356d-4203-8f15-03ddee381998",
+ username: "eric",
+ avatar_url: "https://avatars.githubusercontent.com/u/9683576?v=4",
+ latency_ms: {
+ p50: 34.533,
+ p95: 110.52659999999999,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "78dd2361-4a5a-42b0-9ec3-3eea23af1094",
+ username: "code-asher",
+ avatar_url: "https://avatars.githubusercontent.com/u/45609798?v=4",
+ latency_ms: {
+ p50: 74.78875,
+ p95: 114.80699999999999,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "7f5cc5e9-20ee-48ce-959d-081b3f52273e",
+ username: "mafredri",
+ avatar_url: "https://avatars.githubusercontent.com/u/147409?v=4",
+ latency_ms: {
+ p50: 19.2115,
+ p95: 96.44249999999992,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "9ed91bb9-db45-4cef-b39c-819856e98c30",
+ username: "jon",
+ avatar_url:
+ "https://lh3.googleusercontent.com/a/AAcHTtddhPxiGYniy6_rFhdAi2C1YwKvDButlCvJ6G-166mG=s96-c",
+ latency_ms: {
+ p50: 42.0445,
+ p95: 133.846,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "a73425d1-53a7-43d3-b6ae-cae9ba59b92b",
+ username: "ammar",
+ avatar_url: "https://avatars.githubusercontent.com/u/7416144?v=4",
+ latency_ms: {
+ p50: 49.249,
+ p95: 56.773250000000004,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "af657bc3-6949-4b1b-bc2d-d41a40b546a4",
+ username: "BrunoQuaresma",
+ avatar_url: "https://avatars.githubusercontent.com/u/3165839?v=4",
+ latency_ms: {
+ p50: 82.97,
+ p95: 147.3868,
+ },
+ },
+ {
+ template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"],
+ user_id: "b006209d-fdd2-4716-afb2-104dafb32dfb",
+ username: "mtojek",
+ avatar_url: "https://avatars.githubusercontent.com/u/14044910?v=4",
+ latency_ms: {
+ p50: 36.758,
+ p95: 101.31679999999983,
+ },
+ },
+ ],
+ },
+ },
+ },
+}
diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx
new file mode 100644
index 0000000000000..f701a0c05cc86
--- /dev/null
+++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx
@@ -0,0 +1,354 @@
+import LinearProgress from "@mui/material/LinearProgress"
+import Box from "@mui/material/Box"
+import { styled, useTheme } from "@mui/material/styles"
+import { BoxProps } from "@mui/system"
+import { useQuery } from "@tanstack/react-query"
+import { getInsightsTemplate, getInsightsUserLatency } from "api/api"
+import { DAUChart, DAUTitle } from "components/DAUChart/DAUChart"
+import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout"
+import {
+ HelpTooltip,
+ HelpTooltipTitle,
+ HelpTooltipText,
+} from "components/Tooltips/HelpTooltip"
+import { UserAvatar } from "components/UserAvatar/UserAvatar"
+import { getLatencyColor } from "utils/latency"
+import chroma from "chroma-js"
+import { colors } from "theme/colors"
+import { Helmet } from "react-helmet-async"
+import { getTemplatePageTitle } from "../utils"
+import { Loader } from "components/Loader/Loader"
+import {
+ DAUsResponse,
+ TemplateInsightsResponse,
+ UserLatencyInsightsResponse,
+} from "api/typesGenerated"
+import { ComponentProps } from "react"
+import subDays from "date-fns/subDays"
+
+export default function TemplateInsightsPage() {
+ const { template } = useTemplateLayoutContext()
+ const insightsFilter = {
+ template_ids: template.id,
+ start_time: toTimeFilter(sevenDaysAgo()),
+ end_time: toTimeFilter(new Date()),
+ }
+ const { data: templateInsights } = useQuery({
+ queryKey: ["templates", template.id, "usage"],
+ queryFn: () => getInsightsTemplate(insightsFilter),
+ })
+ const { data: userLatency } = useQuery({
+ queryKey: ["templates", template.id, "user-latency"],
+ queryFn: () => getInsightsUserLatency(insightsFilter),
+ })
+
+ return (
+ <>
+
+ {getTemplatePageTitle("Insights", template)}
+
+
+ >
+ )
+}
+
+export const TemplateInsightsPageView = ({
+ templateInsights,
+ userLatency,
+}: {
+ templateInsights: TemplateInsightsResponse | undefined
+ userLatency: UserLatencyInsightsResponse | undefined
+}) => {
+ return (
+ theme.spacing(3),
+ }}
+ >
+
+
+
+
+ )
+}
+
+const DailyUsersPanel = ({
+ data,
+ ...panelProps
+}: PanelProps & {
+ data: TemplateInsightsResponse["interval_reports"] | undefined
+}) => {
+ return (
+
+
+
+
+
+ Last 7 days
+
+
+ {!data && }
+ {data && data.length === 0 && }
+ {data && data.length > 0 && }
+
+
+ )
+}
+
+const UserLatencyPanel = ({
+ data,
+ ...panelProps
+}: PanelProps & { data: UserLatencyInsightsResponse | undefined }) => {
+ const theme = useTheme()
+ const users = data?.report.users
+
+ return (
+
+
+
+ Latency by user
+
+ How do we calculate latency?
+
+ The average latency of user connections to workspaces.
+
+
+
+ Last 7 days
+
+
+ {!data && }
+ {users && users.length === 0 && }
+ {users &&
+ users
+ .sort((a, b) => b.latency_ms.p95 - a.latency_ms.p95)
+ .map((row) => (
+
+
+
+ {row.username}
+
+
+ {row.latency_ms.p95.toFixed(0)}ms
+
+
+ ))}
+
+
+ )
+}
+
+const TemplateUsagePanel = ({
+ data,
+ ...panelProps
+}: PanelProps & {
+ data: TemplateInsightsResponse["report"]["apps_usage"] | undefined
+}) => {
+ const validUsage = data?.filter((u) => u.seconds > 0)
+ const totalInSeconds =
+ validUsage?.reduce((total, usage) => total + usage.seconds, 0) ?? 1
+ const usageColors = chroma
+ .scale([colors.green[8], colors.blue[8]])
+ .mode("lch")
+ .colors(validUsage?.length ?? 0)
+ // The API returns a row for each app, even if the user didn't use it.
+ const hasDataAvailable = validUsage && validUsage.length > 0
+ return (
+
+
+ App‘s & IDE usage
+ Last 7 days
+
+
+ {!data && }
+ {data && !hasDataAvailable && }
+ {data && hasDataAvailable && (
+
+ {validUsage
+ .sort((a, b) => b.seconds - a.seconds)
+ .map((usage, i) => {
+ const percentage = (usage.seconds / totalInSeconds) * 100
+ return (
+
+
+
+
+
+
+ {usage.display_name}
+
+
+ theme.palette.divider,
+ "& .MuiLinearProgress-bar": {
+ backgroundColor: usageColors[i],
+ borderRadius: 999,
+ },
+ }}
+ />
+ theme.palette.text.secondary,
+ width: 200,
+ flexShrink: 0,
+ }}
+ >
+ {formatTime(usage.seconds)}
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
+
+const Panel = styled(Box)(({ theme }) => ({
+ borderRadius: theme.shape.borderRadius,
+ border: `1px solid ${theme.palette.divider}`,
+ backgroundColor: theme.palette.background.paper,
+ display: "flex",
+ flexDirection: "column",
+}))
+
+type PanelProps = ComponentProps
+
+const PanelHeader = styled(Box)(({ theme }) => ({
+ padding: theme.spacing(2.5, 3, 3),
+}))
+
+const PanelTitle = styled(Box)(() => ({
+ fontSize: 14,
+ fontWeight: 500,
+}))
+
+const PanelSubtitle = styled(Box)(({ theme }) => ({
+ fontSize: 13,
+ color: theme.palette.text.secondary,
+}))
+
+const PanelContent = styled(Box)(({ theme }) => ({
+ padding: theme.spacing(0, 3, 3),
+ flex: 1,
+}))
+
+const NoDataAvailable = (props: BoxProps) => {
+ return (
+ theme.palette.text.secondary,
+ textAlign: "center",
+ height: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ ...props.sx,
+ }}
+ >
+ No data available
+
+ )
+}
+
+function mapToDAUsResponse(
+ data: TemplateInsightsResponse["interval_reports"],
+): DAUsResponse {
+ return {
+ tz_hour_offset: 0,
+ entries: data.map((d) => {
+ return {
+ amount: d.active_users,
+ date: d.end_time,
+ }
+ }),
+ }
+}
+
+function toTimeFilter(date: Date) {
+ date.setHours(0, 0, 0, 0)
+ const year = date.getUTCFullYear()
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0")
+ const day = String(date.getUTCDate()).padStart(2, "0")
+
+ return `${year}-${month}-${day}T00:00:00Z`
+}
+
+function formatTime(seconds: number): string {
+ if (seconds < 60) {
+ return seconds + " seconds"
+ } else if (seconds >= 60 && seconds < 3600) {
+ const minutes = Math.floor(seconds / 60)
+ return minutes + " minutes"
+ } else {
+ const hours = Math.floor(seconds / 3600)
+ const remainingMinutes = Math.floor((seconds % 3600) / 60)
+ if (remainingMinutes === 0) {
+ return hours + " hours"
+ } else {
+ return hours + " hours, " + remainingMinutes + " minutes"
+ }
+ }
+}
+
+function sevenDaysAgo() {
+ return subDays(new Date(), 7)
+}
diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx
index 34faa71dd05fd..e169c42591e54 100644
--- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx
@@ -2,15 +2,16 @@ import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayo
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { getTemplatePageTitle } from "../utils"
-import { useTemplateSummaryData } from "./data"
import { TemplateSummaryPageView } from "./TemplateSummaryPageView"
+import { useQuery } from "@tanstack/react-query"
+import { getTemplateVersionResources } from "api/api"
export const TemplateSummaryPage: FC = () => {
const { template, activeVersion } = useTemplateLayoutContext()
- const { data } = useTemplateSummaryData(
- template.id,
- template.active_version_id,
- )
+ const { data: resources } = useQuery({
+ queryKey: ["templates", template.id, "resources"],
+ queryFn: () => getTemplateVersionResources(activeVersion.id),
+ })
return (
<>
@@ -18,7 +19,7 @@ export const TemplateSummaryPage: FC = () => {
{getTemplatePageTitle("Template", template)}
diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx
index 58f47a8c5a1de..516d70362180e 100644
--- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx
@@ -1,78 +1,66 @@
-import { Story } from "@storybook/react"
+import { Meta, StoryObj } from "@storybook/react"
import {
MockTemplate,
- MockTemplateDAUResponse,
MockTemplateVersion,
MockTemplateVersion3,
MockWorkspaceResource,
MockWorkspaceResource2,
} from "testHelpers/entities"
-import {
- TemplateSummaryPageView,
- TemplateSummaryPageViewProps,
-} from "./TemplateSummaryPageView"
+import { TemplateSummaryPageView } from "./TemplateSummaryPageView"
-export default {
+const meta: Meta = {
title: "pages/TemplateSummaryPageView",
component: TemplateSummaryPageView,
}
-const Template: Story = (args) => (
-
-)
+export default meta
+type Story = StoryObj
-export const Example = Template.bind({})
-Example.args = {
- template: MockTemplate,
- activeVersion: MockTemplateVersion,
- data: {
+export const Example: Story = {
+ args: {
+ template: MockTemplate,
+ activeVersion: MockTemplateVersion,
resources: [MockWorkspaceResource, MockWorkspaceResource2],
- daus: MockTemplateDAUResponse,
},
}
-export const NoIcon = Template.bind({})
-NoIcon.args = {
- template: { ...MockTemplate, icon: "" },
- activeVersion: MockTemplateVersion,
- data: {
+export const NoIcon: Story = {
+ args: {
+ template: { ...MockTemplate, icon: "" },
+ activeVersion: MockTemplateVersion,
resources: [MockWorkspaceResource, MockWorkspaceResource2],
- daus: MockTemplateDAUResponse,
},
}
-export const SmallViewport = Template.bind({})
-SmallViewport.args = {
- template: MockTemplate,
- activeVersion: {
- ...MockTemplateVersion,
- readme: `---
- name:Template test
- ---
- ## Instructions
- You can add instructions here
+export const SmallViewport: Story = {
+ args: {
+ template: MockTemplate,
+ activeVersion: {
+ ...MockTemplateVersion,
+ readme: `---
+ name:Template test
+ ---
+ ## Instructions
+ You can add instructions here
- [Some link info](https://coder.com)
- \`\`\`
- # This is a really long sentence to test that the code block wraps into a new line properly.
- \`\`\`
- `,
- },
- data: {
+ [Some link info](https://coder.com)
+ \`\`\`
+ # This is a really long sentence to test that the code block wraps into a new line properly.
+ \`\`\`
+ `,
+ },
resources: [MockWorkspaceResource, MockWorkspaceResource2],
- daus: MockTemplateDAUResponse,
},
}
+
SmallViewport.parameters = {
chromatic: { viewports: [600] },
}
-export const WithDeprecatedParameters = Template.bind({})
-WithDeprecatedParameters.args = {
- template: MockTemplate,
- activeVersion: MockTemplateVersion3,
- data: {
+export const WithDeprecatedParameters: Story = {
+ args: {
+ template: MockTemplate,
+ activeVersion: MockTemplateVersion3,
resources: [MockWorkspaceResource, MockWorkspaceResource2],
- daus: MockTemplateDAUResponse,
},
}
diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx
index 462b9db37e69a..06eefa264a209 100644
--- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx
+++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx
@@ -8,19 +8,17 @@ import { Stack } from "components/Stack/Stack"
import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable"
import { TemplateStats } from "components/TemplateStats/TemplateStats"
import { FC, useEffect } from "react"
-import { DAUChart } from "../../../components/DAUChart/DAUChart"
-import { TemplateSummaryData } from "./data"
import { useLocation, useNavigate } from "react-router-dom"
import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings"
export interface TemplateSummaryPageViewProps {
- data?: TemplateSummaryData
+ resources?: WorkspaceResource[]
template: Template
activeVersion: TemplateVersion
}
export const TemplateSummaryPageView: FC = ({
- data,
+ resources,
template,
activeVersion,
}) => {
@@ -35,12 +33,10 @@ export const TemplateSummaryPageView: FC = ({
}
}, [template, navigate, location])
- if (!data) {
+ if (!resources) {
return
}
- const { daus, resources } = data
-
const getStartedResources = (resources: WorkspaceResource[]) => {
return resources.filter(
(resource) => resource.workspace_transition === "start",
@@ -51,7 +47,6 @@ export const TemplateSummaryPageView: FC = ({
- {daus && }
)
diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/data.ts b/site/src/pages/TemplatePage/TemplateSummaryPage/data.ts
deleted file mode 100644
index f9457d7549c6c..0000000000000
--- a/site/src/pages/TemplatePage/TemplateSummaryPage/data.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { useQuery } from "@tanstack/react-query"
-import { getTemplateVersionResources, getTemplateDAUs } from "api/api"
-
-const fetchTemplateSummary = async (
- templateId: string,
- activeVersionId: string,
-) => {
- const [resources, daus] = await Promise.all([
- getTemplateVersionResources(activeVersionId),
- getTemplateDAUs(templateId),
- ])
-
- return {
- resources,
- daus,
- }
-}
-
-export const useTemplateSummaryData = (
- templateId: string,
- activeVersionId: string,
-) => {
- return useQuery({
- queryKey: ["template", templateId, "summary"],
- queryFn: () => fetchTemplateSummary(templateId, activeVersionId),
- })
-}
-
-export type TemplateSummaryData = Awaited<
- ReturnType
->
diff --git a/site/yarn.lock b/site/yarn.lock
index 4c2e6ecb5c922..bbbe6d3984c13 100644
--- a/site/yarn.lock
+++ b/site/yarn.lock
@@ -3506,6 +3506,11 @@
"@types/connect" "*"
"@types/node" "*"
+"@types/chroma-js@2.4.0":
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/@types/chroma-js/-/chroma-js-2.4.0.tgz#476a16ae848c77478079d6749236fdb98837b92c"
+ integrity sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw==
+
"@types/color-convert@2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.0.tgz#8f5ee6b9e863dcbee5703f5a517ffb13d3ea4e22"
@@ -4879,6 +4884,11 @@ chownr@^2.0.0:
resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
+chroma-js@2.4.2:
+ version "2.4.2"
+ resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-2.4.2.tgz#dffc214ed0c11fa8eefca2c36651d8e57cbfb2b0"
+ integrity sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A==
+
chromatic@6.20.0:
version "6.20.0"
resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-6.20.0.tgz#28dcbcc254e51bcb887d9b5203b3a11dd5ff890a"