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"