feat(site): add template insights page (#8722)

This commit is contained in:
Bruno Quaresma
2023-07-26 13:13:56 +00:00
committed by GitHub
parent da7e1eb75d
commit 8649a10441
20 changed files with 814 additions and 188 deletions
+4 -2
View File
@@ -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": {
+4 -2
View File
@@ -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": {
+6 -1
View File
@@ -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.
+1
View File
@@ -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
+2
View File
@@ -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",
+5
View File
@@ -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 = () => {
<Route path="files" element={<TemplateFilesPage />} />
<Route path="versions" element={<TemplateVersionsPage />} />
<Route path="embed" element={<TemplateEmbedPage />} />
<Route path="insights" element={<TemplateInsightsPage />} />
</Route>
<Route path="workspace" element={<CreateWorkspacePage />} />
+25
View File
@@ -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<TypesGen.UserLatencyInsightsResponse> => {
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<TypesGen.TemplateInsightsResponse> => {
const params = new URLSearchParams({
...filters,
interval: "day",
})
const response = await axios.get(`/api/v2/insights/templates?${params}`)
return response.data
}
+2
View File
@@ -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",
]
@@ -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(
<DAUChart
daus={{
tz_hour_offset: 0,
entries: [],
}}
/>,
)
await screen.findAllByText(Language.loadingText)
})
it("renders a graph", async () => {
render(
<DAUChart
daus={{
tz_hour_offset: 0,
entries: [{ date: "2020-01-01", amount: 1 }],
}}
/>,
)
await screen.findAllByText(Language.chartTitle)
})
})
+33 -51
View File
@@ -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<DAUChartProps> = ({ 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.
<div style={{ display: "none" }}>
<p>{Language.loadingText}</p>
</div>
)
}
const labels = daus.entries.map((val) => {
return dayjs(val.date).format("YYYY-MM-DD")
})
@@ -92,42 +77,39 @@ export const DAUChart: FC<DAUChartProps> = ({ daus }) => {
},
},
},
aspectRatio: 10 / 1,
maintainAspectRatio: false,
}
return (
<>
<WorkspaceSection
title={
<Stack direction="row" spacing={1} alignItems="center">
{Language.chartTitle}
<HelpTooltip size="small">
<HelpTooltipTitle>How do we calculate DAUs?</HelpTooltipTitle>
<HelpTooltipText>
We use all workspace connection traffic to calculate DAUs.
</HelpTooltipText>
</HelpTooltip>
</Stack>
}
>
<Line
data-chromatic="ignore"
data={{
labels: labels,
datasets: [
{
label: "Daily Active Users",
data: data,
tension: 1 / 4,
backgroundColor: theme.palette.secondary.dark,
borderColor: theme.palette.secondary.dark,
},
],
}}
options={options}
height={400}
/>
</WorkspaceSection>
</>
<Line
data-chromatic="ignore"
data={{
labels: labels,
datasets: [
{
label: "Daily Active Users",
data: data,
tension: 1 / 4,
backgroundColor: theme.palette.secondary.dark,
borderColor: theme.palette.secondary.dark,
},
],
}}
options={options}
/>
)
}
export const DAUTitle = () => {
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
Daily Active Users
<HelpTooltip size="small">
<HelpTooltipTitle>How do we calculate DAUs?</HelpTooltipTitle>
<HelpTooltipText>
We use all workspace connection traffic to calculate DAUs.
</HelpTooltipText>
</HelpTooltip>
</Box>
)
}
+4 -2
View File
@@ -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<React.PropsWithChildren<{ size?: number }>> = ({
export const Loader: FC<{ size?: number } & BoxProps> = ({
size = 26,
...boxProps
}) => {
return (
<Box
@@ -13,6 +14,7 @@ export const Loader: FC<React.PropsWithChildren<{ size?: number }>> = ({
alignItems="center"
justifyContent="center"
data-testid="loader"
{...boxProps}
>
<CircularProgress size={size} />
</Box>
@@ -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
</NavLink>
{shouldShowInsights && (
<NavLink
to={`/templates/${templateName}/insights`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Insights
</NavLink>
)}
</Stack>
</Margins>
</div>
@@ -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) && (
<ErrorAlert error={getDeploymentDAUsError} />
)}
{deploymentDAUs && <DAUChart daus={deploymentDAUs} />}
{deploymentDAUs && (
<Box height={200} sx={{ mb: 3 }}>
<WorkspaceSection title={<DAUTitle />}>
<DAUChart daus={deploymentDAUs} />
</WorkspaceSection>
</Box>
)}
<OptionsTable
options={useDeploymentOptions(
deploymentOptions,
@@ -0,0 +1,290 @@
import type { Meta, StoryObj } from "@storybook/react"
import { TemplateInsightsPageView } from "./TemplateInsightsPage"
const meta: Meta<typeof TemplateInsightsPageView> = {
title: "pages/TemplateInsightsPageView",
component: TemplateInsightsPageView,
}
export default meta
type Story = StoryObj<typeof TemplateInsightsPageView>
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,
},
},
],
},
},
},
}
@@ -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 (
<>
<Helmet>
<title>{getTemplatePageTitle("Insights", template)}</title>
</Helmet>
<TemplateInsightsPageView
templateInsights={templateInsights}
userLatency={userLatency}
/>
</>
)
}
export const TemplateInsightsPageView = ({
templateInsights,
userLatency,
}: {
templateInsights: TemplateInsightsResponse | undefined
userLatency: UserLatencyInsightsResponse | undefined
}) => {
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gridTemplateRows: "440px auto",
gap: (theme) => theme.spacing(3),
}}
>
<DailyUsersPanel
sx={{ gridColumn: "span 2" }}
data={templateInsights?.interval_reports}
/>
<UserLatencyPanel data={userLatency} />
<TemplateUsagePanel
sx={{ gridColumn: "span 3" }}
data={templateInsights?.report.apps_usage}
/>
</Box>
)
}
const DailyUsersPanel = ({
data,
...panelProps
}: PanelProps & {
data: TemplateInsightsResponse["interval_reports"] | undefined
}) => {
return (
<Panel {...panelProps}>
<PanelHeader>
<PanelTitle>
<DAUTitle />
</PanelTitle>
<PanelSubtitle>Last 7 days</PanelSubtitle>
</PanelHeader>
<PanelContent>
{!data && <Loader sx={{ height: "100%" }} />}
{data && data.length === 0 && <NoDataAvailable />}
{data && data.length > 0 && <DAUChart daus={mapToDAUsResponse(data)} />}
</PanelContent>
</Panel>
)
}
const UserLatencyPanel = ({
data,
...panelProps
}: PanelProps & { data: UserLatencyInsightsResponse | undefined }) => {
const theme = useTheme()
const users = data?.report.users
return (
<Panel {...panelProps} sx={{ overflowY: "auto", ...panelProps.sx }}>
<PanelHeader>
<PanelTitle sx={{ display: "flex", alignItems: "center", gap: 1 }}>
Latency by user
<HelpTooltip size="small">
<HelpTooltipTitle>How do we calculate latency?</HelpTooltipTitle>
<HelpTooltipText>
The average latency of user connections to workspaces.
</HelpTooltipText>
</HelpTooltip>
</PanelTitle>
<PanelSubtitle>Last 7 days</PanelSubtitle>
</PanelHeader>
<PanelContent>
{!data && <Loader sx={{ height: "100%" }} />}
{users && users.length === 0 && <NoDataAvailable />}
{users &&
users
.sort((a, b) => b.latency_ms.p95 - a.latency_ms.p95)
.map((row) => (
<Box
key={row.user_id}
sx={{
display: "flex",
justifyContent: "space-between",
fontSize: 14,
py: 1,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<UserAvatar
username={row.username}
avatarURL={row.avatar_url}
/>
<Box sx={{ fontWeight: 500 }}>{row.username}</Box>
</Box>
<Box
sx={{
color: getLatencyColor(theme, row.latency_ms.p95),
fontWeight: 500,
fontSize: 13,
textAlign: "right",
}}
>
{row.latency_ms.p95.toFixed(0)}ms
</Box>
</Box>
))}
</PanelContent>
</Panel>
)
}
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 (
<Panel {...panelProps}>
<PanelHeader>
<PanelTitle>App&lsquo;s & IDE usage</PanelTitle>
<PanelSubtitle>Last 7 days</PanelSubtitle>
</PanelHeader>
<PanelContent>
{!data && <Loader sx={{ height: 200 }} />}
{data && !hasDataAvailable && <NoDataAvailable sx={{ height: 200 }} />}
{data && hasDataAvailable && (
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{validUsage
.sort((a, b) => b.seconds - a.seconds)
.map((usage, i) => {
const percentage = (usage.seconds / totalInSeconds) * 100
return (
<Box
key={usage.slug}
sx={{ display: "flex", gap: 2, alignItems: "center" }}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box
sx={{
width: 20,
height: 20,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
src={usage.icon}
alt=""
style={{
objectFit: "contain",
width: "100%",
height: "100%",
}}
/>
</Box>
<Box sx={{ fontSize: 13, fontWeight: 500, width: 200 }}>
{usage.display_name}
</Box>
</Box>
<LinearProgress
value={percentage}
variant="determinate"
sx={{
width: "100%",
height: 8,
backgroundColor: (theme) => theme.palette.divider,
"& .MuiLinearProgress-bar": {
backgroundColor: usageColors[i],
borderRadius: 999,
},
}}
/>
<Box
sx={{
fontSize: 13,
color: (theme) => theme.palette.text.secondary,
width: 200,
flexShrink: 0,
}}
>
{formatTime(usage.seconds)}
</Box>
</Box>
)
})}
</Box>
)}
</PanelContent>
</Panel>
)
}
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<typeof Panel>
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 (
<Box
{...props}
sx={{
fontSize: 13,
color: (theme) => theme.palette.text.secondary,
textAlign: "center",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
...props.sx,
}}
>
No data available
</Box>
)
}
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)
}
@@ -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 = () => {
<title>{getTemplatePageTitle("Template", template)}</title>
</Helmet>
<TemplateSummaryPageView
data={data}
resources={resources}
template={template}
activeVersion={activeVersion}
/>
@@ -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<typeof TemplateSummaryPageView> = {
title: "pages/TemplateSummaryPageView",
component: TemplateSummaryPageView,
}
const Template: Story<TemplateSummaryPageViewProps> = (args) => (
<TemplateSummaryPageView {...args} />
)
export default meta
type Story = StoryObj<typeof TemplateSummaryPageView>
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,
},
}
@@ -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<TemplateSummaryPageViewProps> = ({
data,
resources,
template,
activeVersion,
}) => {
@@ -35,12 +33,10 @@ export const TemplateSummaryPageView: FC<TemplateSummaryPageViewProps> = ({
}
}, [template, navigate, location])
if (!data) {
if (!resources) {
return <Loader />
}
const { daus, resources } = data
const getStartedResources = (resources: WorkspaceResource[]) => {
return resources.filter(
(resource) => resource.workspace_transition === "start",
@@ -51,7 +47,6 @@ export const TemplateSummaryPageView: FC<TemplateSummaryPageViewProps> = ({
<Stack spacing={4}>
<TemplateVersionWarnings warnings={activeVersion.warnings} />
<TemplateStats template={template} activeVersion={activeVersion} />
{daus && <DAUChart daus={daus} />}
<TemplateResourcesTable resources={getStartedResources(resources)} />
</Stack>
)
@@ -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<typeof fetchTemplateSummary>
>
+10
View File
@@ -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"