Skip to content

Commit f9157c5

Browse files
committed
feat: Allow applying licenses from the config page
https://harperdb.atlassian.net/browse/STUDIO-426
1 parent 853bd97 commit f9157c5

File tree

9 files changed

+273
-13
lines changed

9 files changed

+273
-13
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
3+
import { useApplyLicensesClick } from '@/hooks/useApplyLicensesClick';
4+
import { SchemaLicense } from '@/lib/api.gen';
5+
import { pluralize } from '@/lib/pluralize';
6+
import { RotateCcwIcon } from 'lucide-react';
7+
8+
interface ApplyLicensesButtonParams {
9+
newLicenses: SchemaLicense[];
10+
}
11+
12+
export function ApplyLicensesButton({
13+
newLicenses,
14+
}: ApplyLicensesButtonParams) {
15+
const { onApplyLicensesClick, isApplyLicensesPending } = useApplyLicensesClick({
16+
licenses: newLicenses,
17+
});
18+
const countNewLicenses = pluralize(newLicenses.length, 'New License', 'New Licenses');
19+
const countNewLicensesAre = pluralize(newLicenses.length, 'new license is', 'new licenses are');
20+
return (<Tooltip>
21+
<TooltipTrigger asChild>
22+
<Button
23+
variant="defaultOutline"
24+
className="mx-0 md:mx-4 rounded-full"
25+
onClick={onApplyLicensesClick}
26+
disabled={isApplyLicensesPending}
27+
>
28+
<RotateCcwIcon /> Apply {countNewLicenses}
29+
</Button>
30+
</TooltipTrigger>
31+
<TooltipContent>
32+
{countNewLicensesAre} available for this instance. After applying, you will want to restart the instance.
33+
</TooltipContent>
34+
</Tooltip>);
35+
}

src/features/instance/config/overview/components/ApplicationURL.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const ApplicationURL = ({
1111
return (
1212
<>
1313
<dt className="font-bold text-sm/6">Application URL</dt>
14-
<dd className="text-sm/6 sm:mt-2">{loadingInstanceInfo ? <TextLoadingSkeleton /> : clusterInfo?.fqdn}</dd>
14+
<dd className="text-sm/6 sm:mt-2">{loadingInstanceInfo ? <TextLoadingSkeleton /> : clusterInfo?.fqdn || 'N/A'}</dd>
1515
</>
1616
);
1717
};

src/features/instance/config/overview/components/HarperVersion.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ export const HarperVersion = ({
66
registrationInfo,
77
}: {
88
loadingRegistration?: boolean;
9-
registrationInfo: RegistrationInfoResponse;
9+
registrationInfo?: RegistrationInfoResponse;
1010
}) => {
1111
return (
1212
<>
1313
<dt className="font-bold text-sm/6">Harper Version</dt>
1414
<dd className="text-sm/6 sm:mt-2">
15-
{loadingRegistration ? <TextLoadingSkeleton className="w-10" /> : registrationInfo.version}
15+
{loadingRegistration ? <TextLoadingSkeleton className="w-10" /> : registrationInfo?.version || 'Unknown'}
1616
</dd>
1717
</>
1818
);

src/features/instance/config/overview/components/InstanceNodeName.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const InstanceNodeName = ({
88
loadingInstanceInfo?: boolean;
99
instanceInfo?: { name?: string } | undefined;
1010
}) => {
11-
const simplifiedName = useMemo(() => instanceInfo?.name?.split('.')[0], [instanceInfo?.name]);
11+
const simplifiedName = useMemo(() => instanceInfo?.name?.split('.')[0] || 'N/A', [instanceInfo?.name]);
1212
return (
1313
<>
1414
<dt className="font-bold text-sm/6">Instance Name</dt>
Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { TextLoadingSkeleton } from '@/components/TextLoadingSkeleton';
22
import { Instance } from '@/lib/api.patch';
3+
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';
4+
import { Link } from '@tanstack/react-router';
5+
import { useMemo } from 'react';
36

47
export const InstanceURL = ({
58
loadingInstanceInfo,
@@ -8,10 +11,15 @@ export const InstanceURL = ({
811
loadingInstanceInfo?: boolean;
912
instanceInfo?: Instance | undefined;
1013
}) => {
14+
const instanceUrl = useMemo(() => instanceInfo ? getOperationsUrlForInstance(instanceInfo) : null, [instanceInfo]);
1115
return (
1216
<>
13-
<dt className="font-bold text-sm/6">Instance URL</dt>
14-
<dd className="text-sm/6 sm:mt-2">{loadingInstanceInfo ? <TextLoadingSkeleton /> : instanceInfo?.instanceFqdn}</dd>
17+
<dt className="font-bold text-sm/6">Operations URL</dt>
18+
<dd className="text-sm/6 sm:mt-2">{loadingInstanceInfo
19+
? (<TextLoadingSkeleton />)
20+
: instanceUrl
21+
? (<Link to={instanceUrl} target="_blank" className="underline hover:text-blue-300">{instanceUrl}</Link>)
22+
: 'N/A'}</dd>
1523
</>
1624
);
1725
};

src/features/instance/config/overview/index.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ApplyLicensesButton } from '@/components/ApplyLicensesButton';
12
import { RestartButton } from '@/components/RestartButton';
23
import { TextLoadingSkeleton } from '@/components/TextLoadingSkeleton';
34
import { isLocalStudio } from '@/config/constants';
@@ -9,10 +10,13 @@ import { InstanceNodeName } from '@/features/instance/config/overview/components
910
import { InstanceURL } from '@/features/instance/config/overview/components/InstanceURL';
1011
import { getConfigurationQueryOptions } from '@/features/instance/operations/queries/getConfiguration';
1112
import { getRegistrationInfoQueryOptions } from '@/features/instance/operations/queries/getRegistrationInfo';
13+
import { getUsageLicensesQueryOptions } from '@/features/instance/operations/queries/getUsageLicenses';
14+
import { Instance } from '@/lib/api.patch';
15+
import { keyBy } from '@/lib/keyBy';
1216
import Editor from '@monaco-editor/react';
13-
import { useSuspenseQuery } from '@tanstack/react-query';
14-
import { useParams } from '@tanstack/react-router';
15-
import { ReactNode } from 'react';
17+
import { useQuery } from '@tanstack/react-query';
18+
import { useParams, useRouteContext } from '@tanstack/react-router';
19+
import { ReactNode, useMemo } from 'react';
1620

1721
const LocalStudioOverview = ({ children }: { children: ReactNode }) => {
1822
return <>{children}</>;
@@ -24,21 +28,37 @@ const CloudStudioOverview = ({ children }: { children: ReactNode }) => {
2428

2529
export function ConfigOverviewIndex() {
2630
const { clusterId, instanceId }: { instanceId?: string; clusterId: string; } = useParams({ strict: false });
31+
const { instance: cloudInstance }: { instance?: Instance } = useRouteContext({ strict: false });
2732
const targetNoun = (instanceId || isLocalStudio) ? 'Instance' : 'Cluster';
2833
const instanceParams = useInstanceClientIdParams();
2934

30-
const { data: info, isLoading: loadingInstanceInfo } = useSuspenseQuery(
35+
const { data: info, isLoading: loadingInstanceInfo } = useQuery(
3136
getInstanceInfoQueryOptions({ clusterId, instanceId }),
3237
);
38+
const { data: appliedLicenses } = useQuery(
39+
getUsageLicensesQueryOptions(instanceParams),
40+
);
3341
const clusterInfo = info?.cluster;
3442
const instanceInfo = info?.instance;
35-
const { data: registrationInfo, isLoading: loadingRegistration } = useSuspenseQuery(
43+
const { data: registrationInfo, isLoading: loadingRegistration } = useQuery(
3644
getRegistrationInfoQueryOptions(instanceParams),
3745
);
38-
const { data: configurationInfo, isLoading: loadingConfig } = useSuspenseQuery(
46+
const { data: configurationInfo, isLoading: loadingConfig } = useQuery(
3947
getConfigurationQueryOptions(instanceParams),
4048
);
4149

50+
const newLicenses = useMemo(() => {
51+
if (clusterId && !instanceId) {
52+
// We won't check the licenses when running through a load balancer.
53+
return [];
54+
}
55+
if (appliedLicenses && cloudInstance?.licenses) {
56+
const appliedLicensesById = keyBy(appliedLicenses, 'id');
57+
return cloudInstance.licenses.filter(cloudLicense => !appliedLicensesById[cloudLicense.id]);
58+
}
59+
return [];
60+
}, [clusterId, instanceId, appliedLicenses, cloudInstance]);
61+
4262
return (
4363
<div className="h-full flex flex-col">
4464
{isLocalStudio ? (
@@ -61,7 +81,8 @@ export function ConfigOverviewIndex() {
6181
<div className="px-4 pb-4 sm:col-span-2 sm:px-0">
6282
<InstanceURL loadingInstanceInfo={loadingInstanceInfo} instanceInfo={instanceInfo} />
6383
</div>
64-
<div className="px-4 pb-4 text-right sm:col-span-1 sm:px-0">
84+
<div className="px-4 pb-4 text-right sm:col-span-1 sm:px-0 grid gap-1">
85+
{newLicenses?.length > 0 && (<ApplyLicensesButton newLicenses={newLicenses} />)}
6586
<RestartButton targetNoun={targetNoun} instanceClient={instanceParams.instanceClient} operation="restart" />
6687
</div>
6788
<div className="px-4 pb-4 sm:col-span-1 sm:px-0">
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { InstanceClientConfig } from '@/config/instanceClientConfig';
2+
import { EntityIds } from '@/lib/authStore';
3+
import { useMutation, useQueryClient } from '@tanstack/react-query';
4+
5+
interface InstallUsageLicenseFormData extends InstanceClientConfig {
6+
license: string;
7+
}
8+
9+
export async function installUsageLicense({ license, instanceClient }: InstallUsageLicenseFormData) {
10+
const { data } = await instanceClient.post('/', {
11+
operation: 'install_usage_license',
12+
license,
13+
});
14+
return data;
15+
}
16+
17+
export function useInstallUsageLicenseMutation(entityId: EntityIds) {
18+
const queryClient = useQueryClient();
19+
return useMutation({
20+
mutationFn: installUsageLicense,
21+
onSuccess: () =>
22+
queryClient.invalidateQueries({ queryKey: [entityId, 'get_usage_licenses'] }),
23+
});
24+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { InstanceClientIdConfig } from '@/config/instanceClientConfig';
2+
import { queryOptions } from '@tanstack/react-query';
3+
4+
interface UsageLicense {
5+
id: string;
6+
level: string;
7+
region: string;
8+
reads: string;
9+
writes: string;
10+
readBytes: string;
11+
writeBytes: string;
12+
realTimeMessages: string;
13+
realTimeBytes: string;
14+
cpuTime: string;
15+
storage: string;
16+
usedReads: string;
17+
usedWrites: string;
18+
usedReadBytes: string;
19+
usedWriteBytes: string;
20+
usedRealTimeMessages: string;
21+
usedRealTimeBytes: string;
22+
usedCpuTime: string;
23+
usedStorage: string;
24+
expiration: string;
25+
autoRenew: string;
26+
__createdtime__: string;
27+
__updatedtime__: string;
28+
}
29+
30+
export function getUsageLicensesQueryOptions({ entityId, instanceClient }: InstanceClientIdConfig) {
31+
return queryOptions({
32+
queryKey: [entityId, 'get_usage_licenses'] as const,
33+
queryFn: async () => {
34+
const { data } = await instanceClient.post('/', {
35+
operation: 'get_usage_licenses',
36+
});
37+
return data as UsageLicense[];
38+
},
39+
});
40+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { useInstanceClient } from '@/config/useInstanceClient';
2+
import { installUsageLicense } from '@/features/instance/operations/mutations/installUsageLicense';
3+
import { getInstanceUserInfo } from '@/features/instance/operations/queries/getInstanceUserInfo';
4+
import { SchemaLicense } from '@/lib/api.gen';
5+
import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
6+
import { sleep } from '@/lib/sleep';
7+
import { useCallback, useState } from 'react';
8+
import { toast } from 'sonner';
9+
10+
interface ApplyLicensesClickParams {
11+
licenses: SchemaLicense[];
12+
}
13+
14+
interface ApplyLicensesClickResponse {
15+
onApplyLicensesClick: () => void;
16+
isApplyLicensesPending: boolean;
17+
}
18+
19+
export function useApplyLicensesClick({ licenses }: ApplyLicensesClickParams): ApplyLicensesClickResponse {
20+
const instanceClient = useInstanceClient();
21+
const [isApplyLicensesPending, setIsApplyLicensesPending] = useState(false);
22+
const onApplyLicensesClick = useCallback(async () => {
23+
setIsApplyLicensesPending(true);
24+
25+
let canceled = false;
26+
const toastConfig = {
27+
duration: 60_000,
28+
action: {
29+
label: 'Cancel',
30+
onClick: () => {
31+
canceled = true;
32+
},
33+
},
34+
};
35+
36+
const toastId = toast.loading('Applying Licenses', {
37+
...toastConfig,
38+
description: renderProgressUpdate(),
39+
});
40+
41+
let licensesApplied = 0;
42+
43+
for (let i = 0; i < licenses.length; i++) {
44+
const license = licenses[i];
45+
if (!canceled) {
46+
toast.loading(licenses.length === 1
47+
? 'Applying License'
48+
: `Applying License ${i + 1} of ${licenses.length}`, {
49+
...toastConfig,
50+
id: toastId,
51+
description: renderProgressUpdate(i, licenses.length),
52+
});
53+
try {
54+
// Make sure the instance is responding.
55+
await getInstanceUserInfo({
56+
instanceClient,
57+
});
58+
// Then install the usage license to it.
59+
await installUsageLicense({
60+
license: license.license,
61+
instanceClient,
62+
});
63+
licensesApplied += 1;
64+
} catch {
65+
if (i !== licenses.length) {
66+
// If it fails to applyLicenses, or wasn't available, warn for a bit then move on.
67+
toast.loading(`Failed Applying License ${i + 1} of ${licenses.length}`, {
68+
...toastConfig,
69+
id: toastId,
70+
description: 'We will carry on momentarily.',
71+
});
72+
await sleep(3000);
73+
}
74+
}
75+
}
76+
}
77+
78+
setIsApplyLicensesPending(false);
79+
80+
const licenseWord = licenses.length === 1 ? 'License' : 'Licenses';
81+
if (canceled) {
82+
toast.error('Cancelled', {
83+
id: toastId,
84+
description: `Applying the ${licenseWord.toLowerCase()} was partially cancelled.`,
85+
duration: 10_000,
86+
action: {
87+
label: 'Dismiss',
88+
onClick: () => toast.dismiss(),
89+
},
90+
});
91+
} else if (licenses.length === licensesApplied) {
92+
toast.success('Success', {
93+
id: toastId,
94+
description: `${licenseWord} applied!\nPlease restart your instance.`,
95+
duration: 10_000,
96+
action: {
97+
label: 'Dismiss',
98+
onClick: () => toast.dismiss(),
99+
},
100+
});
101+
} else {
102+
toast.error('Error', {
103+
id: toastId,
104+
description: `${licenseWord} not applied.\n`
105+
+ [
106+
licenses.length !== licensesApplied && `${licensesApplied} of ${licenses.length} ${licenseWord.toLowerCase()} applied.`,
107+
].filter(excludeFalsy)[0],
108+
duration: 10_000,
109+
action: {
110+
label: 'Dismiss',
111+
onClick: () => toast.dismiss(),
112+
},
113+
});
114+
}
115+
}, [instanceClient, licenses]);
116+
117+
118+
return {
119+
onApplyLicensesClick,
120+
isApplyLicensesPending,
121+
};
122+
}
123+
124+
function renderProgressUpdate(current?: number, total?: number) {
125+
return (<>
126+
{current !== undefined && total !== undefined && total > 0 && (
127+
<div className="w-full bg-gray-200 rounded-full h-2.5 dark:bg-gray-700">
128+
<div className="bg-purple-600 h-2.5 rounded-full dark:bg-purple-500" style={{ width: (current === 0 ? 0 : (current / total * 100)) + '%' }}></div>
129+
</div>)}
130+
<div className="text-xs mt-2">Please don't close your browser or navigate away. This may take a bit.</div>
131+
</>);
132+
}

0 commit comments

Comments
 (0)