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:
Bruno Quaresma
2025-01-17 12:37:54 -03:00
committed by GitHub
parent 08ffcb74c6
commit 3217cb85f6
16 changed files with 630 additions and 217 deletions

View File

@ -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> => {

View File

@ -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"]
>;
};

View File

@ -19,7 +19,7 @@ const chartData = [
const chartConfig = {
users: {
label: "Users",
color: "hsl(var(--chart-1))",
color: "hsl(var(--highlight-purple))",
},
} satisfies ChartConfig;

View File

@ -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,
)}
>

View File

@ -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>
);

View File

@ -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%;

View File

@ -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}
/>

View File

@ -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",
},
},
},
},
};

View File

@ -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>

View File

@ -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,
},
};

View File

@ -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>
);
};

View File

@ -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,
},
};

View File

@ -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>
);
};

View File

@ -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();

View File

@ -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&apos;t have any licenses!</span>
<span css={styles.description}>
You&apos;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&apos;t have any licenses!
</span>
<span css={styles.description}>
You&apos;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>
</>
);
};

View File

@ -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: {