feat(site): add support for weekly data on template insights (#9997)

Close https://github.com/coder/coder/issues/9495
This commit is contained in:
Bruno Quaresma
2023-10-03 15:14:11 -03:00
committed by GitHub
parent e7042e601c
commit bdb9954f87
8 changed files with 366 additions and 94 deletions
+11 -8
View File
@@ -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<TypesGen.UserLatencyInsightsResponse> => {
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<TypesGen.TemplateInsightsResponse> => {
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;
};
+15
View File
@@ -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),
};
};
@@ -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<DAUChartProps> = ({ daus }) => {
export const ActiveUserChart: FC<ActiveUserChartProps> = ({
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<DAUChartProps> = ({ 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<DAUChartProps> = ({ 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<DAUChartProps> = ({ daus }) => {
);
};
export const DAUTitle = () => {
export const ActiveUsersTitle = () => {
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
Daily Active Users
Active Users
<HelpTooltip size="small">
<HelpTooltipTitle>
How do we calculate daily active users?
</HelpTooltipTitle>
<HelpTooltipTitle>How do we calculate active users?</HelpTooltipTitle>
<HelpTooltipText>
When a connection is initiated to a user&apos;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
</HelpTooltipText>
</HelpTooltip>
</Box>
@@ -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 && (
<Box height={200} sx={{ mb: 3 }}>
<ChartSection title={<DAUTitle />}>
<DAUChart daus={deploymentDAUs} />
<ChartSection title={<ActiveUsersTitle />}>
<ActiveUserChart data={deploymentDAUs.entries} interval="day" />
</ChartSection>
</Box>
)}
@@ -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<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const handleClose = () => {
setOpen(false);
};
return (
<div>
<Button
ref={anchorRef}
id="interval-button"
aria-controls={open ? "interval-menu" : undefined}
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={() => setOpen(true)}
endIcon={<ExpandMoreOutlined />}
>
{insightsIntervals[value].label}
</Button>
<Menu
id="interval-menu"
anchorEl={anchorRef.current}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "interval-button",
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
{Object.entries(insightsIntervals).map(([interval, { label }]) => {
return (
<MenuItem
css={{ fontSize: 14, justifyContent: "space-between" }}
key={interval}
onClick={() => {
onChange(interval as InsightsInterval);
handleClose();
}}
>
{label}
<Box css={{ width: 16, height: 16 }}>
{value === interval && (
<CheckOutlined css={{ width: 16, height: 16 }} />
)}
</Box>
</MenuItem>
);
})}
</Menu>
</div>
);
};
@@ -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<DateRangeValue>({
startDate: subDays(now, 6),
endDate: now,
});
const { template } = useTemplateLayoutContext();
const insightsFilter = {
template_ids: template.id,
...getDateRangeFilter({
startDate: dateRangeValue.startDate,
endDate: dateRangeValue.endDate,
now,
isToday,
}),
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 { 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 commonFilters = {
template_ids: template.id,
...getDateRangeFilter(dateRange),
};
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() {
<title>{getTemplatePageTitle("Insights", template)}</title>
</Helmet>
<TemplateInsightsPageView
dateRange={
<DateRange value={dateRangeValue} onChange={setDateRangeValue} />
controls={
<>
<IntervalMenu
value={interval}
onChange={(interval) => {
// 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" ? (
<DailyPicker value={dateRange} onChange={setDateRange} />
) : (
<WeekPicker value={dateRange} onChange={setDateRange} />
)}
</>
}
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 (
<>
<Box sx={{ mb: 4 }}>{dateRange}</Box>
<Box
css={(theme) => ({
marginBottom: theme.spacing(4),
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
})}
>
{controls}
</Box>
<Box
sx={{
display: "grid",
@@ -99,8 +165,9 @@ export const TemplateInsightsPageView = ({
gap: (theme) => theme.spacing(3),
}}
>
<DailyUsersPanel
<ActiveUsersPanel
sx={{ gridColumn: "span 2" }}
interval={interval}
data={templateInsights?.interval_reports}
/>
<UserLatencyPanel data={userLatency} />
@@ -117,23 +184,33 @@ export const TemplateInsightsPageView = ({
);
};
const DailyUsersPanel = ({
const ActiveUsersPanel = ({
data,
interval,
...panelProps
}: PanelProps & {
data: TemplateInsightsResponse["interval_reports"] | undefined;
interval: InsightsInterval;
}) => {
return (
<Panel {...panelProps}>
<PanelHeader>
<PanelTitle>
<DAUTitle />
<ActiveUsersTitle />
</PanelTitle>
</PanelHeader>
<PanelContent>
{!data && <Loader sx={{ height: "100%" }} />}
{data && data.length === 0 && <NoDataAvailable />}
{data && data.length > 0 && <DAUChart daus={mapToDAUsResponse(data)} />}
{data && data.length > 0 && (
<ActiveUserChart
interval={interval}
data={data.map((d) => ({
amount: d.active_users,
date: d.start_time,
}))}
/>
)}
</PanelContent>
</Panel>
);
@@ -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";
@@ -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<HTMLButtonElement>(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 (
<div>
<Button
ref={anchorRef}
id="interval-button"
aria-controls={open ? "interval-menu" : undefined}
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={() => setOpen(true)}
endIcon={<ExpandMoreOutlined />}
>
Last {numberOfWeeks} weeks
</Button>
<Menu
id="interval-menu"
anchorEl={anchorRef.current}
open={open}
onClose={handleClose}
MenuListProps={{
"aria-labelledby": "interval-button",
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
{numberOfWeeksOptions.map((option) => {
const optionRange = lastWeeks(option);
return (
<MenuItem
css={{ fontSize: 14, justifyContent: "space-between" }}
key={option}
onClick={() => {
onChange(optionRange);
handleClose();
}}
>
Last {option} weeks
<Box css={{ width: 16, height: 16 }}>
{numberOfWeeks === option && (
<CheckOutlined css={{ width: 16, height: 16 }} />
)}
</Box>
</MenuItem>
);
})}
</Menu>
</div>
);
};
@@ -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 };
};