mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: improve usage visibility (#16134)
- Refactor the DAUs chart for clarity by improving the description and updating its title to better reflect the data. - Add a license consumption chart to the licenses page.
This commit is contained in:
@ -2089,6 +2089,19 @@ class ApiMethods {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getInsightsUserStatusCounts = async (
|
||||
offset = Math.trunc(new Date().getTimezoneOffset() / 60),
|
||||
): Promise<TypesGen.GetUserStatusCountsResponse> => {
|
||||
const searchParams = new URLSearchParams({
|
||||
tz_offset: offset.toString(),
|
||||
});
|
||||
const response = await this.axios.get(
|
||||
`/api/v2/insights/user-status-counts?${searchParams}`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
getInsightsTemplate = async (
|
||||
params: InsightsTemplateParams,
|
||||
): Promise<TypesGen.TemplateInsightsResponse> => {
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { API, type InsightsParams, type InsightsTemplateParams } from "api/api";
|
||||
import type { GetUserStatusCountsResponse } from "api/typesGenerated";
|
||||
import { type UseQueryOptions, UseQueryResult } from "react-query";
|
||||
|
||||
export const insightsTemplate = (params: InsightsTemplateParams) => {
|
||||
return {
|
||||
@ -20,3 +22,15 @@ export const insightsUserActivity = (params: InsightsParams) => {
|
||||
queryFn: () => API.getInsightsUserActivity(params),
|
||||
};
|
||||
};
|
||||
|
||||
export const insightsUserStatusCounts = () => {
|
||||
return {
|
||||
queryKey: ["insights", "userStatusCounts"],
|
||||
queryFn: () => API.getInsightsUserStatusCounts(),
|
||||
select: (data) => data.status_counts,
|
||||
} satisfies UseQueryOptions<
|
||||
GetUserStatusCountsResponse,
|
||||
unknown,
|
||||
GetUserStatusCountsResponse["status_counts"]
|
||||
>;
|
||||
};
|
||||
|
@ -19,7 +19,7 @@ const chartData = [
|
||||
const chartConfig = {
|
||||
users: {
|
||||
label: "Users",
|
||||
color: "hsl(var(--chart-1))",
|
||||
color: "hsl(var(--highlight-purple))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
|
@ -66,6 +66,8 @@ export const ChartContainer = React.forwardRef<
|
||||
"[&_.recharts-sector[stroke='#fff']]:stroke-transparent",
|
||||
"[&_.recharts-sector]:outline-none",
|
||||
"[&_.recharts-surface]:outline-none",
|
||||
"[&_.recharts-text]:fill-content-secondary [&_.recharts-text]:font-medium",
|
||||
"[&_.recharts-cartesian-axis-line]:stroke-[hsl(var(--border-default))]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@ -195,7 +197,7 @@ export const ChartTooltipContent = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
"grid min-w-[8rem] items-start gap-1 rounded-lg border border-solid border-border bg-surface-primary px-3 py-2 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { Slot, Slottable } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { SquareArrowOutUpRightIcon } from "lucide-react";
|
||||
import { forwardRef } from "react";
|
||||
@ -38,7 +38,7 @@ export const Link = forwardRef<HTMLAnchorElement, LinkProps>(
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Slottable>{children}</Slottable>
|
||||
<SquareArrowOutUpRightIcon aria-hidden="true" />
|
||||
</Comp>
|
||||
);
|
||||
|
@ -28,11 +28,8 @@
|
||||
--border-success: 142 76% 36%;
|
||||
--border-destructive: 0 84% 60%;
|
||||
--radius: 0.5rem;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--highlight-purple: 262 83% 58%;
|
||||
--highlight-green: 143 64% 24%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 10% 3.9%;
|
||||
@ -59,11 +56,8 @@
|
||||
--border-default: 240 4% 16%;
|
||||
--border-success: 142 76% 36%;
|
||||
--border-destructive: 0 91% 71%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
--highlight-purple: 252 95% 85%;
|
||||
--highlight-green: 141 79% 85%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
@ -11,11 +11,9 @@ import { GeneralSettingsPageView } from "./GeneralSettingsPageView";
|
||||
|
||||
const GeneralSettingsPage: FC = () => {
|
||||
const { deploymentConfig } = useDeploymentSettings();
|
||||
const deploymentDAUsQuery = useQuery(deploymentDAUs());
|
||||
const safeExperimentsQuery = useQuery(availableExperiments());
|
||||
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
|
||||
const enabledExperimentsQuery = useQuery(experiments(metadata.experiments));
|
||||
|
||||
const safeExperiments = safeExperimentsQuery.data?.safe ?? [];
|
||||
@ -24,6 +22,8 @@ const GeneralSettingsPage: FC = () => {
|
||||
return !safeExperiments.includes(exp);
|
||||
}) ?? [];
|
||||
|
||||
const { data: dailyActiveUsers } = useQuery(deploymentDAUs());
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@ -31,9 +31,7 @@ const GeneralSettingsPage: FC = () => {
|
||||
</Helmet>
|
||||
<GeneralSettingsPageView
|
||||
deploymentOptions={deploymentConfig.options}
|
||||
deploymentDAUs={deploymentDAUsQuery.data}
|
||||
deploymentDAUsError={deploymentDAUsQuery.error}
|
||||
entitlements={entitlementsQuery.data}
|
||||
dailyActiveUsers={dailyActiveUsers}
|
||||
invalidExperiments={invalidExperiments}
|
||||
safeExperiments={safeExperiments}
|
||||
/>
|
||||
|
@ -1,9 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockDeploymentDAUResponse,
|
||||
MockEntitlementsWithUserLimit,
|
||||
mockApiError,
|
||||
} from "testHelpers/entities";
|
||||
import { MockDeploymentDAUResponse } from "testHelpers/entities";
|
||||
import { GeneralSettingsPageView } from "./GeneralSettingsPageView";
|
||||
|
||||
const meta: Meta<typeof GeneralSettingsPageView> = {
|
||||
@ -39,10 +35,9 @@ const meta: Meta<typeof GeneralSettingsPageView> = {
|
||||
hidden: false,
|
||||
},
|
||||
],
|
||||
deploymentDAUs: MockDeploymentDAUResponse,
|
||||
dailyActiveUsers: MockDeploymentDAUResponse,
|
||||
invalidExperiments: [],
|
||||
safeExperiments: [],
|
||||
entitlements: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@ -51,21 +46,6 @@ type Story = StoryObj<typeof GeneralSettingsPageView>;
|
||||
|
||||
export const Page: Story = {};
|
||||
|
||||
export const NoDAUs: Story = {
|
||||
args: {
|
||||
deploymentDAUs: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const DAUError: Story = {
|
||||
args: {
|
||||
deploymentDAUs: undefined,
|
||||
deploymentDAUsError: mockApiError({
|
||||
message: "Error fetching DAUs.",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const allExperimentsEnabled: Story = {
|
||||
args: {
|
||||
deploymentOptions: [
|
||||
@ -137,74 +117,3 @@ export const invalidExperimentsEnabled: Story = {
|
||||
invalidExperiments: ["invalid"],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLicenseUtilization: Story = {
|
||||
args: {
|
||||
entitlements: {
|
||||
...MockEntitlementsWithUserLimit,
|
||||
features: {
|
||||
...MockEntitlementsWithUserLimit.features,
|
||||
user_limit: {
|
||||
...MockEntitlementsWithUserLimit.features.user_limit,
|
||||
enabled: true,
|
||||
actual: 75,
|
||||
limit: 100,
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const HighLicenseUtilization: Story = {
|
||||
args: {
|
||||
entitlements: {
|
||||
...MockEntitlementsWithUserLimit,
|
||||
features: {
|
||||
...MockEntitlementsWithUserLimit.features,
|
||||
user_limit: {
|
||||
...MockEntitlementsWithUserLimit.features.user_limit,
|
||||
enabled: true,
|
||||
actual: 95,
|
||||
limit: 100,
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ExceedsLicenseUtilization: Story = {
|
||||
args: {
|
||||
entitlements: {
|
||||
...MockEntitlementsWithUserLimit,
|
||||
features: {
|
||||
...MockEntitlementsWithUserLimit.features,
|
||||
user_limit: {
|
||||
...MockEntitlementsWithUserLimit.features.user_limit,
|
||||
enabled: true,
|
||||
actual: 100,
|
||||
limit: 95,
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export const NoLicenseLimit: Story = {
|
||||
args: {
|
||||
entitlements: {
|
||||
...MockEntitlementsWithUserLimit,
|
||||
features: {
|
||||
...MockEntitlementsWithUserLimit.features,
|
||||
user_limit: {
|
||||
...MockEntitlementsWithUserLimit.features.user_limit,
|
||||
enabled: false,
|
||||
actual: 0,
|
||||
limit: 0,
|
||||
entitlement: "entitled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -1,16 +1,10 @@
|
||||
import AlertTitle from "@mui/material/AlertTitle";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
import type {
|
||||
DAUsResponse,
|
||||
Entitlements,
|
||||
Experiments,
|
||||
SerpentOption,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
ActiveUserChart,
|
||||
ActiveUsersTitle,
|
||||
} from "components/ActiveUserChart/ActiveUserChart";
|
||||
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import type { FC } from "react";
|
||||
@ -18,31 +12,21 @@ import { useDeploymentOptions } from "utils/deployOptions";
|
||||
import { docs } from "utils/docs";
|
||||
import { Alert } from "../../../components/Alert/Alert";
|
||||
import OptionsTable from "../OptionsTable";
|
||||
import { ChartSection } from "./ChartSection";
|
||||
import { UserEngagementChart } from "./UserEngagementChart";
|
||||
|
||||
export type GeneralSettingsPageViewProps = {
|
||||
deploymentOptions: SerpentOption[];
|
||||
deploymentDAUs?: DAUsResponse;
|
||||
deploymentDAUsError: unknown;
|
||||
entitlements: Entitlements | undefined;
|
||||
dailyActiveUsers: DAUsResponse | undefined;
|
||||
readonly invalidExperiments: Experiments | string[];
|
||||
readonly safeExperiments: Experiments | string[];
|
||||
};
|
||||
|
||||
export const GeneralSettingsPageView: FC<GeneralSettingsPageViewProps> = ({
|
||||
deploymentOptions,
|
||||
deploymentDAUs,
|
||||
deploymentDAUsError,
|
||||
entitlements,
|
||||
dailyActiveUsers,
|
||||
safeExperiments,
|
||||
invalidExperiments,
|
||||
}) => {
|
||||
const licenseUtilizationPercentage =
|
||||
entitlements?.features?.user_limit?.actual &&
|
||||
entitlements?.features?.user_limit?.limit
|
||||
? entitlements.features.user_limit.actual /
|
||||
entitlements.features.user_limit.limit
|
||||
: undefined;
|
||||
return (
|
||||
<>
|
||||
<SettingsHeader
|
||||
@ -51,47 +35,12 @@ export const GeneralSettingsPageView: FC<GeneralSettingsPageViewProps> = ({
|
||||
docsHref={docs("/admin/setup")}
|
||||
/>
|
||||
<Stack spacing={4}>
|
||||
{Boolean(deploymentDAUsError) && (
|
||||
<ErrorAlert error={deploymentDAUsError} />
|
||||
)}
|
||||
{deploymentDAUs && (
|
||||
<div css={{ marginBottom: 24, height: 200 }}>
|
||||
<ChartSection title={<ActiveUsersTitle interval="day" />}>
|
||||
<ActiveUserChart data={deploymentDAUs.entries} interval="day" />
|
||||
</ChartSection>
|
||||
</div>
|
||||
)}
|
||||
{licenseUtilizationPercentage && (
|
||||
<ChartSection title="License Utilization">
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(licenseUtilizationPercentage * 100, 100)}
|
||||
color={
|
||||
licenseUtilizationPercentage < 0.9
|
||||
? "primary"
|
||||
: licenseUtilizationPercentage < 1
|
||||
? "warning"
|
||||
: "error"
|
||||
}
|
||||
css={{
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
css={{
|
||||
fontSize: "0.75rem",
|
||||
display: "block",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{Math.round(licenseUtilizationPercentage * 100)}% used (
|
||||
{entitlements!.features.user_limit.actual}/
|
||||
{entitlements!.features.user_limit.limit} users)
|
||||
</span>
|
||||
</ChartSection>
|
||||
)}
|
||||
<UserEngagementChart
|
||||
data={dailyActiveUsers?.entries.map((i) => ({
|
||||
date: i.date,
|
||||
users: i.amount,
|
||||
}))}
|
||||
/>
|
||||
{invalidExperiments.length > 0 && (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>Invalid experiments in use:</AlertTitle>
|
||||
|
@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { UserEngagementChart } from "./UserEngagementChart";
|
||||
|
||||
const meta: Meta<typeof UserEngagementChart> = {
|
||||
title: "pages/DeploymentSettingsPage/GeneralSettingsPage/UserEngagementChart",
|
||||
component: UserEngagementChart,
|
||||
args: {
|
||||
data: [
|
||||
{ date: "1/1/2024", users: 150 },
|
||||
{ date: "1/2/2024", users: 165 },
|
||||
{ date: "1/3/2024", users: 180 },
|
||||
{ date: "1/4/2024", users: 155 },
|
||||
{ date: "1/5/2024", users: 190 },
|
||||
{ date: "1/6/2024", users: 200 },
|
||||
{ date: "1/7/2024", users: 210 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof UserEngagementChart>;
|
||||
|
||||
export const Loaded: Story = {};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
data: undefined,
|
||||
},
|
||||
};
|
@ -0,0 +1,187 @@
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "components/Chart/Chart";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "components/Collapsible/Collapsible";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
|
||||
const chartConfig = {
|
||||
users: {
|
||||
label: "Users",
|
||||
color: "hsl(var(--highlight-purple))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export type UserEngagementChartProps = {
|
||||
data:
|
||||
| {
|
||||
date: string;
|
||||
users: number;
|
||||
}[]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const UserEngagementChart: FC<UserEngagementChartProps> = ({ data }) => {
|
||||
return (
|
||||
<section className="border border-solid rounded">
|
||||
<div className="p-4">
|
||||
<Collapsible>
|
||||
<header className="flex flex-col gap-2 items-start">
|
||||
<h3 className="text-md m-0 font-medium">User Engagement</h3>
|
||||
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={`
|
||||
h-auto p-0 border-0 bg-transparent font-medium text-content-secondary
|
||||
hover:bg-transparent hover:text-content-primary
|
||||
[&[data-state=open]_svg]:rotate-90
|
||||
`}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
How we calculate engaged users
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</header>
|
||||
|
||||
<CollapsibleContent
|
||||
className={`
|
||||
pt-2 pl-7 pr-5 space-y-4 font-medium max-w-[720px]
|
||||
[&_p]:m-0 [&_p]:text-sm [&_p]:text-content-secondary
|
||||
`}
|
||||
>
|
||||
<p>
|
||||
A user is considered "engaged" if they initiate a connection to
|
||||
their workspace via apps, web terminal, or SSH. The graph displays
|
||||
the daily count of unique users who engaged at least once, with
|
||||
additional insights available through the{" "}
|
||||
<Link size="sm" asChild>
|
||||
<RouterLink to="/audit">Activity Audit</RouterLink>
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link size="sm" asChild>
|
||||
<RouterLink to="/deployment/licenses">
|
||||
License Consumption
|
||||
</RouterLink>
|
||||
</Link>{" "}
|
||||
tools.
|
||||
</p>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-0 border-t border-solid">
|
||||
<div className="h-64">
|
||||
{data ? (
|
||||
data.length > 0 ? (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-full"
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
top: 10,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
tickMargin={12}
|
||||
minTickGap={24}
|
||||
tickFormatter={(value: string) =>
|
||||
new Date(value).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
dataKey="users"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={12}
|
||||
tickFormatter={(value: number) => {
|
||||
return value === 0 ? "" : value.toLocaleString();
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="font-medium text-content-secondary"
|
||||
labelClassName="text-content-primary"
|
||||
labelFormatter={(_, p) => {
|
||||
const item = p[0];
|
||||
return `${item.value} users`;
|
||||
}}
|
||||
formatter={(v, n, item) => {
|
||||
const date = new Date(item.payload.date);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "long",
|
||||
day: "2-digit",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="fillUsers" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-users)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-users)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<Area
|
||||
dataKey="users"
|
||||
type="natural"
|
||||
fill="url(#fillUsers)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-users)"
|
||||
stackId="a"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div
|
||||
className={`
|
||||
w-full h-full flex items-center justify-center
|
||||
text-content-secondary text-sm font-medium
|
||||
`}
|
||||
>
|
||||
No data available
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart";
|
||||
|
||||
const meta: Meta<typeof LicenseSeatConsumptionChart> = {
|
||||
title:
|
||||
"pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseSeatConsumptionChart",
|
||||
component: LicenseSeatConsumptionChart,
|
||||
args: {
|
||||
limit: 220,
|
||||
data: [
|
||||
{ date: "1/1/2024", users: 150 },
|
||||
{ date: "1/2/2024", users: 165 },
|
||||
{ date: "1/3/2024", users: 180 },
|
||||
{ date: "1/4/2024", users: 155 },
|
||||
{ date: "1/5/2024", users: 190 },
|
||||
{ date: "1/6/2024", users: 200 },
|
||||
{ date: "1/7/2024", users: 210 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LicenseSeatConsumptionChart>;
|
||||
|
||||
export const Loaded: Story = {};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
data: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
data: undefined,
|
||||
},
|
||||
};
|
@ -0,0 +1,251 @@
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "components/Chart/Chart";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "components/Collapsible/Collapsible";
|
||||
import { Link } from "components/Link/Link";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ReferenceLine,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
const chartConfig = {
|
||||
users: {
|
||||
label: "Users",
|
||||
color: "hsl(var(--highlight-green))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export type LicenseSeatConsumptionChartProps = {
|
||||
limit: number | undefined;
|
||||
data:
|
||||
| {
|
||||
date: string;
|
||||
users: number;
|
||||
}[]
|
||||
| undefined;
|
||||
};
|
||||
|
||||
export const LicenseSeatConsumptionChart: FC<
|
||||
LicenseSeatConsumptionChartProps
|
||||
> = ({ data, limit }) => {
|
||||
return (
|
||||
<section className="border border-solid rounded">
|
||||
<div className="p-4">
|
||||
<Collapsible>
|
||||
<header className="flex flex-col gap-2 items-start">
|
||||
<h3 className="text-md m-0 font-medium">
|
||||
License seat consumption
|
||||
</h3>
|
||||
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={`
|
||||
h-auto p-0 border-0 bg-transparent font-medium text-content-secondary
|
||||
hover:bg-transparent hover:text-content-primary
|
||||
[&[data-state=open]_svg]:rotate-90
|
||||
`}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
How we calculate license seat consumption
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</header>
|
||||
|
||||
<CollapsibleContent
|
||||
className={`
|
||||
pt-2 pl-7 pr-5 space-y-4 font-medium max-w-[720px]
|
||||
text-sm text-content-secondary
|
||||
[&_p]:m-0 [&_ul]:m-0 [&_ul]:p-0 [&_ul]:list-none
|
||||
`}
|
||||
>
|
||||
<p>
|
||||
Licenses are consumed based on the status of user accounts. Only
|
||||
Active user accounts are consuming license seats.
|
||||
</p>
|
||||
<ul>
|
||||
<li className="flex items-center gap-2">
|
||||
<div
|
||||
className="rounded-[2px] bg-highlight-green size-3 inline-block"
|
||||
aria-label="Legend for active users in the chart"
|
||||
/>
|
||||
The user was active at least once during the last 90 days.
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<div
|
||||
className="size-3 inline-flex items-center justify-center"
|
||||
aria-label="Legend for license seat limit in the chart"
|
||||
>
|
||||
<div className="w-full border-b-1 border-t-1 border-dashed border-content-disabled" />
|
||||
</div>
|
||||
Current license seat limit, or the maximum number of allowed
|
||||
Active accounts.
|
||||
</li>
|
||||
</ul>
|
||||
<div>
|
||||
You might also check:
|
||||
<ul>
|
||||
<li>
|
||||
<Link asChild>
|
||||
<RouterLink to="/audit">Activity Audit</RouterLink>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link asChild>
|
||||
<RouterLink to="/deployment/general">
|
||||
Daily user activity
|
||||
</RouterLink>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={docs("/admin/users#user-status")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
More details on user account statuses
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-0 border-t border-solid">
|
||||
<div className="h-64">
|
||||
{data ? (
|
||||
data.length > 0 ? (
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="aspect-auto h-full"
|
||||
>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={data}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 5,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickLine={false}
|
||||
tickMargin={12}
|
||||
minTickGap={24}
|
||||
tickFormatter={(value: string) =>
|
||||
new Date(value).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
// Adds space on Y to show always show the reference line without overflowing it.
|
||||
domain={[0, limit ? "dataMax + 10" : "auto"]}
|
||||
dataKey="users"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={12}
|
||||
tickFormatter={(value: number) => {
|
||||
return value === 0 ? "" : value.toLocaleString();
|
||||
}}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="font-medium text-content-secondary"
|
||||
labelClassName="text-content-primary"
|
||||
labelFormatter={(_, p) => {
|
||||
const item = p[0];
|
||||
return `${item.value} licenses`;
|
||||
}}
|
||||
formatter={(v, n, item) => {
|
||||
const date = new Date(item.payload.date);
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "long",
|
||||
day: "2-digit",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="fillUsers" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-users)"
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-users)"
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<Area
|
||||
dataKey="users"
|
||||
type="natural"
|
||||
fill="url(#fillUsers)"
|
||||
fillOpacity={0.4}
|
||||
stroke="var(--color-users)"
|
||||
stackId="a"
|
||||
/>
|
||||
{limit && (
|
||||
<ReferenceLine
|
||||
isFront
|
||||
ifOverflow="extendDomain"
|
||||
y={limit}
|
||||
label={{
|
||||
value: "license seat limit",
|
||||
position: "insideBottomRight",
|
||||
className:
|
||||
"text-2xs text-content-secondary font-regular",
|
||||
}}
|
||||
stroke="hsl(var(--content-disabled))"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div
|
||||
className={`
|
||||
w-full h-full flex items-center justify-center
|
||||
text-content-secondary text-sm font-medium
|
||||
`}
|
||||
>
|
||||
No data available
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Spinner loading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
import { API } from "api/api";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { entitlements, refreshEntitlements } from "api/queries/entitlements";
|
||||
import { insightsUserStatusCounts } from "api/queries/insights";
|
||||
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
|
||||
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
@ -19,6 +20,8 @@ const LicensesSettingsPage: FC = () => {
|
||||
const { metadata } = useEmbeddedMetadata();
|
||||
const entitlementsQuery = useQuery(entitlements(metadata.entitlements));
|
||||
|
||||
const { data: userStatusCount } = useQuery(insightsUserStatusCounts());
|
||||
|
||||
const refreshEntitlementsMutation = useMutation(
|
||||
refreshEntitlements(queryClient),
|
||||
);
|
||||
@ -80,6 +83,7 @@ const LicensesSettingsPage: FC = () => {
|
||||
licenses={licenses}
|
||||
isRemovingLicense={isRemovingLicense}
|
||||
removeLicense={(licenseId: number) => removeLicenseApi(licenseId)}
|
||||
activeUsers={userStatusCount?.active}
|
||||
refreshEntitlements={async () => {
|
||||
try {
|
||||
await refreshEntitlementsMutation.mutateAsync();
|
||||
|
@ -7,13 +7,16 @@ import MuiLink from "@mui/material/Link";
|
||||
import Skeleton from "@mui/material/Skeleton";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import type { GetLicensesResponse } from "api/api";
|
||||
import type { UserStatusChangeCount } from "api/typesGenerated";
|
||||
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { useWindowSize } from "hooks/useWindowSize";
|
||||
import type { FC } from "react";
|
||||
import Confetti from "react-confetti";
|
||||
import { Link } from "react-router-dom";
|
||||
import { license } from "../../../../e2e/constants";
|
||||
import { LicenseCard } from "./LicenseCard";
|
||||
import { LicenseSeatConsumptionChart } from "./LicenseSeatConsumptionChart";
|
||||
|
||||
type Props = {
|
||||
showConfetti: boolean;
|
||||
@ -25,6 +28,7 @@ type Props = {
|
||||
isRefreshing: boolean;
|
||||
removeLicense: (licenseId: number) => void;
|
||||
refreshEntitlements: () => void;
|
||||
activeUsers: UserStatusChangeCount[] | undefined;
|
||||
};
|
||||
|
||||
const LicensesSettingsPageView: FC<Props> = ({
|
||||
@ -37,6 +41,7 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
isRefreshing,
|
||||
removeLicense,
|
||||
refreshEntitlements,
|
||||
activeUsers,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { width, height } = useWindowSize();
|
||||
@ -50,6 +55,7 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
numberOfPieces={showConfetti ? 200 : 0}
|
||||
colors={[theme.palette.primary.main, theme.palette.secondary.main]}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
alignItems="baseline"
|
||||
direction="row"
|
||||
@ -81,47 +87,64 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{isLoading && <Skeleton variant="rectangular" height={200} />}
|
||||
<div className="flex flex-col gap-4">
|
||||
{isLoading && (
|
||||
<Skeleton className="rounded" variant="rectangular" height={78} />
|
||||
)}
|
||||
|
||||
{!isLoading && licenses && licenses?.length > 0 && (
|
||||
<Stack spacing={4} className="licenses">
|
||||
{licenses
|
||||
?.sort(
|
||||
(a, b) =>
|
||||
new Date(b.claims.license_expires).valueOf() -
|
||||
new Date(a.claims.license_expires).valueOf(),
|
||||
)
|
||||
.map((license) => (
|
||||
<LicenseCard
|
||||
key={license.id}
|
||||
license={license}
|
||||
userLimitActual={userLimitActual}
|
||||
userLimitLimit={userLimitLimit}
|
||||
isRemoving={isRemovingLicense}
|
||||
onRemove={removeLicense}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{!isLoading && licenses === null && (
|
||||
<div css={styles.root}>
|
||||
<Stack alignItems="center" spacing={1}>
|
||||
<Stack alignItems="center" spacing={0.5}>
|
||||
<span css={styles.title}>You don't have any licenses!</span>
|
||||
<span css={styles.description}>
|
||||
You're missing out on high availability, RBAC, quotas, and
|
||||
much more. Contact{" "}
|
||||
<MuiLink href="mailto:sales@coder.com">sales</MuiLink> or{" "}
|
||||
<MuiLink href="https://coder.com/trial">
|
||||
request a trial license
|
||||
</MuiLink>{" "}
|
||||
to get started.
|
||||
</span>
|
||||
</Stack>
|
||||
{!isLoading && licenses && licenses?.length > 0 && (
|
||||
<Stack spacing={4} className="licenses">
|
||||
{licenses
|
||||
?.sort(
|
||||
(a, b) =>
|
||||
new Date(b.claims.license_expires).valueOf() -
|
||||
new Date(a.claims.license_expires).valueOf(),
|
||||
)
|
||||
.map((license) => (
|
||||
<LicenseCard
|
||||
key={license.id}
|
||||
license={license}
|
||||
userLimitActual={userLimitActual}
|
||||
userLimitLimit={userLimitLimit}
|
||||
isRemoving={isRemovingLicense}
|
||||
onRemove={removeLicense}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!isLoading && licenses === null && (
|
||||
<div css={styles.root}>
|
||||
<Stack alignItems="center" spacing={1}>
|
||||
<Stack alignItems="center" spacing={0.5}>
|
||||
<span css={styles.title}>
|
||||
You don't have any licenses!
|
||||
</span>
|
||||
<span css={styles.description}>
|
||||
You're missing out on high availability, RBAC, quotas,
|
||||
and much more. Contact{" "}
|
||||
<MuiLink href="mailto:sales@coder.com">sales</MuiLink> or{" "}
|
||||
<MuiLink href="https://coder.com/trial">
|
||||
request a trial license
|
||||
</MuiLink>{" "}
|
||||
to get started.
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{licenses && licenses.length > 0 && (
|
||||
<LicenseSeatConsumptionChart
|
||||
limit={userLimitLimit}
|
||||
data={activeUsers?.map((i) => ({
|
||||
date: i.date,
|
||||
users: i.count,
|
||||
limit: 80,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -50,12 +50,9 @@ module.exports = {
|
||||
},
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
1: "hsl(var(--chart-1))",
|
||||
2: "hsl(var(--chart-2))",
|
||||
3: "hsl(var(--chart-3))",
|
||||
4: "hsl(var(--chart-4))",
|
||||
5: "hsl(var(--chart-5))",
|
||||
highlight: {
|
||||
purple: "hsl(var(--highlight-purple))",
|
||||
green: "hsl(var(--highlight-green))",
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
|
Reference in New Issue
Block a user