mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: show user limit on active users chart (#10101)
This commit is contained in:
@@ -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",
|
||||
|
||||
Generated
+11
@@ -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'}
|
||||
|
||||
@@ -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<ActiveUserChartProps> = ({
|
||||
data,
|
||||
interval,
|
||||
userLimit,
|
||||
}) => {
|
||||
const theme: Theme = useTheme();
|
||||
|
||||
@@ -57,6 +63,24 @@ export const ActiveUserChart: FC<ActiveUserChartProps> = ({
|
||||
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 = () => {
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+12
-1
@@ -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<typeof GeneralSettingsPageView> = {
|
||||
@@ -44,6 +48,13 @@ type Story = StoryObj<typeof GeneralSettingsPageView>;
|
||||
|
||||
export const Page: Story = {};
|
||||
|
||||
export const WithUserLimit: Story = {
|
||||
args: {
|
||||
deploymentDAUs: MockDeploymentDAUResponse,
|
||||
entitlements: MockEntitlementsWithUserLimit,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoDAUs: Story = {
|
||||
args: {
|
||||
deploymentDAUs: undefined,
|
||||
|
||||
@@ -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 && (
|
||||
<Box height={200} sx={{ mb: 3 }}>
|
||||
<ChartSection title={<ActiveUsersTitle />}>
|
||||
<ActiveUserChart data={deploymentDAUs.entries} interval="day" />
|
||||
<ActiveUserChart
|
||||
data={deploymentDAUs.entries}
|
||||
interval="day"
|
||||
userLimit={
|
||||
entitlements?.features.user_limit.enabled
|
||||
? entitlements?.features.user_limit.limit
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ChartSection>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { TemplateInsightsPageView } from "./TemplateInsightsPage";
|
||||
import { MockEntitlementsWithUserLimit } from "testHelpers/entities";
|
||||
|
||||
const meta: Meta<typeof TemplateInsightsPageView> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = ({
|
||||
<ActiveUsersPanel
|
||||
sx={{ gridColumn: "span 2" }}
|
||||
interval={interval}
|
||||
userLimit={
|
||||
entitlements?.features.user_limit.enabled
|
||||
? entitlements?.features.user_limit.limit
|
||||
: undefined
|
||||
}
|
||||
data={templateInsights?.interval_reports}
|
||||
/>
|
||||
<UsersLatencyPanel data={userLatency} />
|
||||
@@ -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 (
|
||||
<Panel {...panelProps}>
|
||||
@@ -216,6 +229,7 @@ const ActiveUsersPanel = ({
|
||||
{data && data.length > 0 && (
|
||||
<ActiveUserChart
|
||||
interval={interval}
|
||||
userLimit={userLimit}
|
||||
data={data.map((d) => ({
|
||||
amount: d.active_users,
|
||||
date: d.start_time,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user