diff --git a/site/package.json b/site/package.json index 6c1a5d4d5b927..110a7dafd55c1 100644 --- a/site/package.json +++ b/site/package.json @@ -54,6 +54,7 @@ "canvas": "2.11.0", "chart.js": "4.4.0", "chartjs-adapter-date-fns": "3.0.0", + "chartjs-plugin-annotation": "3.0.1", "chroma-js": "2.4.2", "color-convert": "2.0.1", "cron-parser": "4.9.0", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index 546b9d703d7a5..89b223a31ae21 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -78,6 +78,9 @@ dependencies: chartjs-adapter-date-fns: specifier: 3.0.0 version: 3.0.0(chart.js@4.4.0)(date-fns@2.30.0) + chartjs-plugin-annotation: + specifier: 3.0.1 + version: 3.0.1(chart.js@4.4.0) chroma-js: specifier: 2.4.2 version: 2.4.2 @@ -7179,6 +7182,14 @@ packages: date-fns: 2.30.0 dev: false + /chartjs-plugin-annotation@3.0.1(chart.js@4.4.0): + resolution: {integrity: sha512-hlIrXXKqSDgb+ZjVYHefmlZUXK8KbkCPiynSVrTb/HjTMkT62cOInaT1NTQCKtxKKOm9oHp958DY3RTAFKtkHg==} + peerDependencies: + chart.js: '>=4.0.0' + dependencies: + chart.js: 4.4.0 + dev: false + /chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} diff --git a/site/src/components/ActiveUserChart/ActiveUserChart.tsx b/site/src/components/ActiveUserChart/ActiveUserChart.tsx index c4471356fcb11..15ff04864ccb1 100644 --- a/site/src/components/ActiveUserChart/ActiveUserChart.tsx +++ b/site/src/components/ActiveUserChart/ActiveUserChart.tsx @@ -24,6 +24,7 @@ import { import dayjs from "dayjs"; import { FC } from "react"; import { Line } from "react-chartjs-2"; +import annotationPlugin from "chartjs-plugin-annotation"; ChartJS.register( CategoryScale, @@ -35,16 +36,21 @@ ChartJS.register( Title, Tooltip, Legend, + annotationPlugin, ); +const USER_LIMIT_DISPLAY_THRESHOLD = 60; + export interface ActiveUserChartProps { data: { date: string; amount: number }[]; interval: "day" | "week"; + userLimit: number | undefined; } export const ActiveUserChart: FC = ({ data, interval, + userLimit, }) => { const theme: Theme = useTheme(); @@ -57,6 +63,24 @@ export const ActiveUserChart: FC = ({ const options: ChartOptions<"line"> = { responsive: true, plugins: { + annotation: { + annotations: [ + { + type: "line", + scaleID: "y", + display: shouldDisplayUserLimit(userLimit, chartData), + value: userLimit, + borderColor: theme.palette.secondary.contrastText, + borderWidth: 5, + label: { + content: "User limit", + color: theme.palette.primary.contrastText, + display: true, + font: { weight: "normal" }, + }, + }, + ], + }, legend: { display: false, }, @@ -127,3 +151,15 @@ export const ActiveUsersTitle = () => { ); }; + +function shouldDisplayUserLimit( + userLimit: number | undefined, + activeUsers: number[], +): boolean { + if (!userLimit || activeUsers.length === 0) { + return false; + } + return ( + Math.max(...activeUsers) >= (userLimit * USER_LIMIT_DISPLAY_THRESHOLD) / 100 + ); +} diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx index a0a8f314c06be..a36e7c4415e4a 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -5,10 +5,12 @@ import { pageTitle } from "utils/page"; import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; import { useQuery } from "react-query"; import { deploymentDAUs } from "api/queries/deployment"; +import { entitlements } from "api/queries/entitlements"; const GeneralSettingsPage: FC = () => { const { deploymentValues } = useDeploySettings(); const deploymentDAUsQuery = useQuery(deploymentDAUs()); + const entitlementsQuery = useQuery(entitlements()); return ( <> @@ -19,6 +21,7 @@ const GeneralSettingsPage: FC = () => { deploymentOptions={deploymentValues.options} deploymentDAUs={deploymentDAUsQuery.data} deploymentDAUsError={deploymentDAUsQuery.error} + entitlements={entitlementsQuery.data} /> ); diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx index 549e72f1767ae..8f6726dbe7243 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx @@ -1,5 +1,9 @@ import { Meta, StoryObj } from "@storybook/react"; -import { mockApiError, MockDeploymentDAUResponse } from "testHelpers/entities"; +import { + mockApiError, + MockDeploymentDAUResponse, + MockEntitlementsWithUserLimit, +} from "testHelpers/entities"; import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; const meta: Meta = { @@ -44,6 +48,13 @@ type Story = StoryObj; export const Page: Story = {}; +export const WithUserLimit: Story = { + args: { + deploymentDAUs: MockDeploymentDAUResponse, + entitlements: MockEntitlementsWithUserLimit, + }, +}; + export const NoDAUs: Story = { args: { deploymentDAUs: undefined, diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index 03c4e44a93195..21623d813ee50 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -1,5 +1,5 @@ import Box from "@mui/material/Box"; -import { ClibaseOption, DAUsResponse } from "api/typesGenerated"; +import { ClibaseOption, DAUsResponse, Entitlements } from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { ActiveUserChart, @@ -16,11 +16,13 @@ export type GeneralSettingsPageViewProps = { deploymentOptions: ClibaseOption[]; deploymentDAUs?: DAUsResponse; deploymentDAUsError: unknown; + entitlements: Entitlements | undefined; }; export const GeneralSettingsPageView = ({ deploymentOptions, deploymentDAUs, deploymentDAUsError, + entitlements, }: GeneralSettingsPageViewProps): JSX.Element => { return ( <> @@ -36,7 +38,15 @@ export const GeneralSettingsPageView = ({ {deploymentDAUs && ( }> - + )} diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx index 88ac7f34a5892..28d686468ce5a 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react"; import { TemplateInsightsPageView } from "./TemplateInsightsPage"; +import { MockEntitlementsWithUserLimit } from "testHelpers/entities"; const meta: Meta = { title: "pages/TemplateInsightsPageView", @@ -515,7 +516,7 @@ export const Loaded: Story = { end_time: "2023-07-25T00:00:00Z", template_ids: ["0d286645-29aa-4eaf-9b52-cc5d2740c90b"], interval: "day", - active_users: 11, + active_users: 16, }, ], }, @@ -861,3 +862,11 @@ export const Loaded: Story = { }, }, }; + +export const LoadedWithUserLimit: Story = { + ...Loaded, + args: { + ...Loaded.args, + entitlements: MockEntitlementsWithUserLimit, + }, +}; diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index d3ea3d3c9952a..90fb11a1be789 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -21,6 +21,7 @@ import { Helmet } from "react-helmet-async"; import { getTemplatePageTitle } from "../utils"; import { Loader } from "components/Loader/Loader"; import { + Entitlements, Template, TemplateAppUsage, TemplateInsightsResponse, @@ -48,6 +49,7 @@ import { insightsUserLatency, } from "api/queries/insights"; import { useSearchParams } from "react-router-dom"; +import { entitlements } from "api/queries/entitlements"; const DEFAULT_NUMBER_OF_WEEKS = numberOfWeeksOptions[0]; @@ -75,6 +77,7 @@ export default function TemplateInsightsPage() { const { data: templateInsights } = useQuery(insightsTemplate(insightsFilter)); const { data: userLatency } = useQuery(insightsUserLatency(commonFilters)); const { data: userActivity } = useQuery(insightsUserActivity(commonFilters)); + const { data: entitlementsQuery } = useQuery(entitlements()); return ( <> @@ -106,6 +109,7 @@ export default function TemplateInsightsPage() { userLatency={userLatency} userActivity={userActivity} interval={interval} + entitlements={entitlementsQuery} /> ); @@ -146,12 +150,14 @@ export const TemplateInsightsPageView = ({ templateInsights, userLatency, userActivity, + entitlements, controls, interval, }: { templateInsights: TemplateInsightsResponse | undefined; userLatency: UserLatencyInsightsResponse | undefined; userActivity: UserActivityInsightsResponse | undefined; + entitlements: Entitlements | undefined; controls: ReactNode; interval: InsightsInterval; }) => { @@ -178,6 +184,11 @@ export const TemplateInsightsPageView = ({ @@ -198,10 +209,12 @@ export const TemplateInsightsPageView = ({ const ActiveUsersPanel = ({ data, interval, + userLimit, ...panelProps }: PanelProps & { data: TemplateInsightsResponse["interval_reports"] | undefined; interval: InsightsInterval; + userLimit: number | undefined; }) => { return ( @@ -216,6 +229,7 @@ const ActiveUsersPanel = ({ {data && data.length > 0 && ( ({ amount: d.active_users, date: d.start_time, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 67bbff360b6e6..4d5b28a869747 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -30,9 +30,9 @@ export const MockTemplateDAUResponse: TypesGen.DAUsResponse = { export const MockDeploymentDAUResponse: TypesGen.DAUsResponse = { tz_hour_offset: 0, entries: [ - { date: "2022-08-27T00:00:00Z", amount: 1 }, - { date: "2022-08-29T00:00:00Z", amount: 2 }, - { date: "2022-08-30T00:00:00Z", amount: 1 }, + { date: "2022-08-27T00:00:00Z", amount: 10 }, + { date: "2022-08-29T00:00:00Z", amount: 22 }, + { date: "2022-08-30T00:00:00Z", amount: 14 }, ], }; export const MockSessionToken: TypesGen.LoginWithPasswordResponse = { @@ -1925,6 +1925,22 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = { }), }; +export const MockEntitlementsWithUserLimit: TypesGen.Entitlements = { + errors: [], + warnings: [], + has_license: true, + require_telemetry: false, + trial: false, + refreshed_at: "2022-05-20T16:45:57.122Z", + features: withDefaultFeatures({ + user_limit: { + enabled: true, + entitlement: "entitled", + limit: 25, + }, + }), +}; + export const MockExperiments: TypesGen.Experiment[] = ["moons"]; export const MockAuditLog: TypesGen.AuditLog = {