diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 9dceab9621351..09a9eb760bf3c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -1474,28 +1474,31 @@ export const getWorkspaceParameters = async (workspace: TypesGen.Workspace) => { }; }; -type InsightsFilter = { +export type InsightsParams = { start_time: string; end_time: string; template_ids: string; }; export const getInsightsUserLatency = async ( - filters: InsightsFilter, + filters: InsightsParams, ): Promise => { const params = new URLSearchParams(filters); const response = await axios.get(`/api/v2/insights/user-latency?${params}`); return response.data; }; +export type InsightsTemplateParams = InsightsParams & { + interval: "day" | "week"; +}; + export const getInsightsTemplate = async ( - filters: InsightsFilter, + params: InsightsTemplateParams, ): Promise => { - const params = new URLSearchParams({ - ...filters, - interval: "day", - }); - const response = await axios.get(`/api/v2/insights/templates?${params}`); + const searchParams = new URLSearchParams(params); + const response = await axios.get( + `/api/v2/insights/templates?${searchParams}`, + ); return response.data; }; diff --git a/site/src/api/queries/insights.ts b/site/src/api/queries/insights.ts new file mode 100644 index 0000000000000..f3aef1ba58ca0 --- /dev/null +++ b/site/src/api/queries/insights.ts @@ -0,0 +1,15 @@ +import * as API from "api/api"; + +export const insightsTemplate = (params: API.InsightsTemplateParams) => { + return { + queryKey: ["insights", "templates", params.template_ids, params], + queryFn: () => API.getInsightsTemplate(params), + }; +}; + +export const insightsUserLatency = (params: API.InsightsParams) => { + return { + queryKey: ["insights", "userLatency", params.template_ids, params], + queryFn: () => API.getInsightsUserLatency(params), + }; +}; diff --git a/site/src/components/DAUChart/DAUChart.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.tsx similarity index 76% rename from site/src/components/DAUChart/DAUChart.tsx rename to site/src/components/ActiveUserChart/ActiveUserChart.tsx index 16b013a78c54c..35b812c6cf478 100644 --- a/site/src/components/DAUChart/DAUChart.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.tsx @@ -1,7 +1,6 @@ import Box from "@mui/material/Box"; import { Theme } from "@mui/material/styles"; import useTheme from "@mui/styles/useTheme"; -import * as TypesGen from "api/typesGenerated"; import { CategoryScale, Chart as ChartJS, @@ -38,20 +37,19 @@ ChartJS.register( Legend, ); -export interface DAUChartProps { - daus: TypesGen.DAUsResponse; +export interface ActiveUserChartProps { + data: { date: string; amount: number }[]; + interval: "day" | "week"; } -export const DAUChart: FC = ({ daus }) => { +export const ActiveUserChart: FC = ({ + data, + interval, +}) => { const theme: Theme = useTheme(); - const labels = daus.entries.map((val) => { - return dayjs(val.date).format("YYYY-MM-DD"); - }); - - const data = daus.entries.map((val) => { - return val.amount; - }); + const labels = data.map((val) => dayjs(val.date).format("YYYY-MM-DD")); + const chartData = data.map((val) => val.amount); defaults.font.family = theme.typography.fontFamily as string; defaults.color = theme.palette.text.secondary; @@ -82,11 +80,11 @@ export const DAUChart: FC = ({ daus }) => { x: { ticks: { - stepSize: daus.entries.length > 10 ? 2 : undefined, + stepSize: data.length > 10 ? 2 : undefined, }, type: "time", time: { - unit: "day", + unit: interval, }, }, }, @@ -101,7 +99,7 @@ export const DAUChart: FC = ({ daus }) => { datasets: [ { label: "Daily Active Users", - data: data, + data: chartData, pointBackgroundColor: theme.palette.info.light, pointBorderColor: theme.palette.info.light, borderColor: theme.palette.info.light, @@ -115,17 +113,15 @@ export const DAUChart: FC = ({ daus }) => { ); }; -export const DAUTitle = () => { +export const ActiveUsersTitle = () => { return ( - Daily Active Users + Active Users - - How do we calculate daily active users? - + How do we calculate active users? When a connection is initiated to a user's workspace they are - considered a daily active user. e.g. apps, web terminal, SSH + considered an active user. e.g. apps, web terminal, SSH diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index 378d2d431b938..03c4e44a93195 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -1,7 +1,10 @@ import Box from "@mui/material/Box"; import { ClibaseOption, DAUsResponse } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; -import { DAUChart, DAUTitle } from "components/DAUChart/DAUChart"; +import { + ActiveUserChart, + ActiveUsersTitle, +} from "components/ActiveUserChart/ActiveUserChart"; import { Header } from "components/DeploySettingsLayout/Header"; import OptionsTable from "components/DeploySettingsLayout/OptionsTable"; import { Stack } from "components/Stack/Stack"; @@ -32,8 +35,8 @@ export const GeneralSettingsPageView = ({ )} {deploymentDAUs && ( - }> - + }> + )} diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/IntervalMenu.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/IntervalMenu.tsx new file mode 100644 index 0000000000000..797e1b4995f10 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/IntervalMenu.tsx @@ -0,0 +1,86 @@ +import CheckOutlined from "@mui/icons-material/CheckOutlined"; +import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { useState, useRef } from "react"; + +export const insightsIntervals = { + day: { + label: "Daily", + }, + week: { + label: "Weekly", + }, +} as const; + +export type InsightsInterval = keyof typeof insightsIntervals; + +export const IntervalMenu = ({ + value, + onChange, +}: { + value: InsightsInterval; + onChange: (value: InsightsInterval) => void; +}) => { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleClose = () => { + setOpen(false); + }; + + return ( +
+ + + {Object.entries(insightsIntervals).map(([interval, { label }]) => { + return ( + { + onChange(interval as InsightsInterval); + handleClose(); + }} + > + {label} + + {value === interval && ( + + )} + + + ); + })} + +
+ ); +}; diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index d9c1444af4ec3..25226c9bc89fd 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -3,8 +3,10 @@ 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 { + ActiveUsersTitle, + ActiveUserChart, +} from "components/ActiveUserChart/ActiveUserChart"; import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout"; import { HelpTooltip, @@ -19,49 +21,53 @@ import { Helmet } from "react-helmet-async"; import { getTemplatePageTitle } from "../utils"; import { Loader } from "components/Loader/Loader"; import { - DAUsResponse, + Template, TemplateAppUsage, TemplateInsightsResponse, TemplateParameterUsage, TemplateParameterValue, UserLatencyInsightsResponse, } from "api/typesGenerated"; -import { ComponentProps, ReactNode, useState } from "react"; -import { subDays, isToday } from "date-fns"; +import { ComponentProps, ReactNode } from "react"; +import { subDays, addWeeks } from "date-fns"; import "react-date-range/dist/styles.css"; import "react-date-range/dist/theme/default.css"; -import { DateRange, DateRangeValue } from "./DateRange"; +import { DateRange as DailyPicker, DateRangeValue } from "./DateRange"; import Link from "@mui/material/Link"; import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; import CancelOutlined from "@mui/icons-material/CancelOutlined"; -import { getDateRangeFilter } from "./utils"; +import { getDateRangeFilter, lastWeeks } from "./utils"; import Tooltip from "@mui/material/Tooltip"; import LinkOutlined from "@mui/icons-material/LinkOutlined"; +import { InsightsInterval, IntervalMenu } from "./IntervalMenu"; +import { WeekPicker, numberOfWeeksOptions } from "./WeekPicker"; +import { insightsTemplate, insightsUserLatency } from "api/queries/insights"; +import { useSearchParams } from "react-router-dom"; + +const DEFAULT_NUMBER_OF_WEEKS = numberOfWeeksOptions[0]; export default function TemplateInsightsPage() { - const now = new Date(); - const [dateRangeValue, setDateRangeValue] = useState({ - startDate: subDays(now, 6), - endDate: now, - }); const { template } = useTemplateLayoutContext(); - const insightsFilter = { + const [searchParams, setSearchParams] = useSearchParams(); + + const defaultInterval = getDefaultInterval(template); + const interval = + (searchParams.get("interval") as InsightsInterval) || defaultInterval; + + const dateRange = getDateRange(searchParams, interval); + const setDateRange = (newDateRange: DateRangeValue) => { + searchParams.set("startDate", newDateRange.startDate.toISOString()); + searchParams.set("endDate", newDateRange.endDate.toISOString()); + setSearchParams(searchParams); + }; + + const commonFilters = { template_ids: template.id, - ...getDateRangeFilter({ - startDate: dateRangeValue.startDate, - endDate: dateRangeValue.endDate, - now, - isToday, - }), + ...getDateRangeFilter(dateRange), }; - const { data: templateInsights } = useQuery({ - queryKey: ["templates", template.id, "usage", insightsFilter], - queryFn: () => getInsightsTemplate(insightsFilter), - }); - const { data: userLatency } = useQuery({ - queryKey: ["templates", template.id, "user-latency", insightsFilter], - queryFn: () => getInsightsUserLatency(insightsFilter), - }); + const insightsFilter = { ...commonFilters, interval }; + const { data: templateInsights } = useQuery(insightsTemplate(insightsFilter)); + const { data: userLatency } = useQuery(insightsUserLatency(commonFilters)); return ( <> @@ -69,28 +75,88 @@ export default function TemplateInsightsPage() { {getTemplatePageTitle("Insights", template)} + controls={ + <> + { + // When going from daily to week we need to set a safe week range + if (interval === "week") { + setDateRange(lastWeeks(DEFAULT_NUMBER_OF_WEEKS)); + } + searchParams.set("interval", interval); + setSearchParams(searchParams); + }} + /> + {interval === "day" ? ( + + ) : ( + + )} + } templateInsights={templateInsights} userLatency={userLatency} + interval={interval} /> ); } +const getDefaultInterval = (template: Template) => { + const now = new Date(); + const templateCreateDate = new Date(template.created_at); + const hasFiveWeeksOrMore = addWeeks(templateCreateDate, 5) < now; + return hasFiveWeeksOrMore ? "week" : "day"; +}; + +const getDateRange = ( + searchParams: URLSearchParams, + interval: InsightsInterval, +) => { + const startDate = searchParams.get("startDate"); + const endDate = searchParams.get("endDate"); + + if (startDate && endDate) { + return { + startDate: new Date(startDate), + endDate: new Date(endDate), + }; + } + + if (interval === "day") { + return { + startDate: subDays(new Date(), 6), + endDate: new Date(), + }; + } + + return lastWeeks(DEFAULT_NUMBER_OF_WEEKS); +}; + export const TemplateInsightsPageView = ({ templateInsights, userLatency, - dateRange, + controls, + interval, }: { templateInsights: TemplateInsightsResponse | undefined; userLatency: UserLatencyInsightsResponse | undefined; - dateRange: ReactNode; + controls: ReactNode; + interval: InsightsInterval; }) => { return ( <> - {dateRange} + ({ + marginBottom: theme.spacing(4), + display: "flex", + alignItems: "center", + gap: theme.spacing(1), + })} + > + {controls} + theme.spacing(3), }} > - @@ -117,23 +184,33 @@ export const TemplateInsightsPageView = ({ ); }; -const DailyUsersPanel = ({ +const ActiveUsersPanel = ({ data, + interval, ...panelProps }: PanelProps & { data: TemplateInsightsResponse["interval_reports"] | undefined; + interval: InsightsInterval; }) => { return ( - + {!data && } {data && data.length === 0 && } - {data && data.length > 0 && } + {data && data.length > 0 && ( + ({ + amount: d.active_users, + date: d.start_time, + }))} + /> + )} ); @@ -584,22 +661,6 @@ const TextValue = ({ children }: { children: ReactNode }) => { ); }; -function mapToDAUsResponse( - data: TemplateInsightsResponse["interval_reports"], -): DAUsResponse { - return { - tz_hour_offset: 0, - entries: data - ? data.map((d) => { - return { - amount: d.active_users, - date: d.start_time, - }; - }) - : [], - }; -} - function formatTime(seconds: number): string { if (seconds < 60) { return seconds + " seconds"; diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/WeekPicker.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/WeekPicker.tsx new file mode 100644 index 0000000000000..2ef0dae1f8cb1 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/WeekPicker.tsx @@ -0,0 +1,84 @@ +import CheckOutlined from "@mui/icons-material/CheckOutlined"; +import ExpandMoreOutlined from "@mui/icons-material/ExpandMoreOutlined"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import { useState, useRef } from "react"; +import { DateRangeValue } from "./DateRange"; +import { differenceInWeeks } from "date-fns"; +import { lastWeeks } from "./utils"; + +export const numberOfWeeksOptions = [4, 12, 24, 48] as const; + +export const WeekPicker = ({ + value, + onChange, +}: { + value: DateRangeValue; + onChange: (value: DateRangeValue) => void; +}) => { + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + // Why +1? If you get the week 1 and week 2 the diff is 1, but there are 2 weeks + const numberOfWeeks = differenceInWeeks(value.endDate, value.startDate) + 1; + + const handleClose = () => { + setOpen(false); + }; + + return ( +
+ + + {numberOfWeeksOptions.map((option) => { + const optionRange = lastWeeks(option); + + return ( + { + onChange(optionRange); + handleClose(); + }} + > + Last {option} weeks + + {numberOfWeeks === option && ( + + )} + + + ); + })} + +
+ ); +}; diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts index 13c3991c78ad2..f447bff1a331e 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts @@ -1,16 +1,33 @@ -import { addDays, addHours, format, startOfDay, startOfHour } from "date-fns"; +import { + addDays, + addHours, + format, + startOfDay, + startOfHour, + isToday as isTodayDefault, + startOfWeek, + endOfDay, + endOfWeek, + isSunday, + subWeeks, +} from "date-fns"; -export function getDateRangeFilter({ - startDate, - endDate, - now, - isToday, -}: { +type GetDateRangeFilterOptions = { startDate: Date; endDate: Date; - now: Date; - isToday: (date: Date) => boolean; -}) { + // Testing purposes + now?: Date; + isToday?: (date: Date) => boolean; +}; + +export function getDateRangeFilter(props: GetDateRangeFilterOptions) { + const { + startDate, + endDate, + now = new Date(), + isToday = isTodayDefault, + } = props; + return { start_time: toISOLocal(startOfDay(startDate)), end_time: toISOLocal( @@ -24,3 +41,10 @@ export function getDateRangeFilter({ function toISOLocal(d: Date) { return format(d, "yyyy-MM-dd'T'HH:mm:ssxxx"); } + +export const lastWeeks = (numberOfWeeks: number) => { + const now = new Date(); + const startDate = startOfWeek(subWeeks(now, numberOfWeeks)); + const endDate = isSunday(now) ? endOfDay(now) : endOfWeek(subWeeks(now, 1)); + return { startDate, endDate }; +};