Skip to content

Commit a9ca4e2

Browse files
committed
1 parent 3340eeb commit a9ca4e2

File tree

3 files changed

+230
-18
lines changed

3 files changed

+230
-18
lines changed

src/features/instance/apis/APIDocs.tsx

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { ErrorComponent } from '@/components/ErrorComponent';
22
import { Loading } from '@/components/Loading';
3+
import { Button } from '@/components/ui/button';
34
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
45
import { plugins } from '@/features/instance/apis/plugins';
56
import { requestSnippets } from '@/features/instance/apis/requestSnippets';
67
import { getConfigurationQueryOptions } from '@/features/instance/operations/queries/getConfiguration';
78
import { getOpenAPIQueryOptions } from '@/features/instance/operations/queries/getOpenAPI';
89
import { getRegistrationInfoQueryOptions } from '@/features/instance/operations/queries/getRegistrationInfo';
10+
import { useRollingConfigUpdate } from '@/hooks/useRollingConfigUpdate';
911
import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB';
1012
import { useQuery } from '@tanstack/react-query';
1113
import { useParams } from '@tanstack/react-router';
14+
import { Plus } from 'lucide-react';
15+
import { useCallback } from 'react';
1216
import SwaggerUI from 'swagger-ui-react';
1317
import 'swagger-ui-react/swagger-ui.css';
1418
import './swagger.css';
@@ -28,41 +32,67 @@ export function APIDocs() {
2832
isLoading: isLoadingDocs,
2933
error,
3034
} = useQuery(getOpenAPIQueryOptions(operationsParams));
35+
const http = configurationInfo?.http;
36+
37+
let apiInaccessibleWarning: string = '';
38+
39+
const corsDisabled = http?.cors === false;
40+
const missingFromCORSList = http?.corsAccessList?.length !== undefined
41+
&& !http.corsAccessList.includes('*')
42+
&& !http.corsAccessList.includes('null')
43+
&& !http.corsAccessList.includes(window.location.origin);
44+
if (corsDisabled) {
45+
apiInaccessibleWarning = `This ${clusterId && !instanceId ? 'cluster' : 'instance'} has CORS disabled currently, so you won't be able to execute API requests from the browser.`;
46+
} else if (missingFromCORSList) {
47+
apiInaccessibleWarning = `This ${clusterId && !instanceId ? 'cluster' : 'instance'} has CORS enabled, but ${window.location.origin} is not allowed, so you won't be able to execute API requests from the browser.`;
48+
}
49+
50+
const { onConfigUpdate, isPending: isSettingConfiguration } = useRollingConfigUpdate();
51+
const enableCORS = useCallback(() => {
52+
onConfigUpdate({
53+
...(corsDisabled ? {
54+
'http_cors': true,
55+
} : {}),
56+
...(missingFromCORSList ? {
57+
'http_corsAccessList': [
58+
...(http?.corsAccessList ?? []),
59+
window.location.origin,
60+
],
61+
} : {}),
62+
});
63+
}, [http, onConfigUpdate]);
3164

3265
if (isLoadingConfiguration || isLoadingRegistration || isLoadingDocs) {
3366
return <Loading centered={true} text="Looking up your instance configuration, one moment." />;
3467
}
3568

3669
if (error) {
70+
let message: string;
3771
if (registrationInfo?.version && !wasAReleasedBeforeB('4.7.0-beta.7', registrationInfo?.version)) {
38-
return <ErrorComponent
39-
title="API Docs Unavailable"
40-
error={{
41-
message: `API Docs are only available starting in version '4.7.0-beta.7' of Harper, please update your version ${registrationInfo.version}!`,
42-
}}
43-
showReturnToHome={false}
44-
/>;
72+
message = `API Docs are only available starting in version '4.7.0-beta.7' of Harper, please update your version ${registrationInfo.version}!`;
73+
} else {
74+
message = `We weren't able to look up your docs. Please check the Network tab of your developer tools to see why the docs were not accessible to Studio.`;
4575
}
4676

4777
return <ErrorComponent
4878
title="API Docs Unavailable"
49-
error={{
50-
message: 'We weren\'t able to look up your docs. Please check the Network tab of your' +
51-
' developer tools to see why the docs were not accessible to Studio.',
52-
}}
79+
error={{ message }}
5380
showReturnToHome={false}
5481
/>;
5582
}
5683

5784
return (<>
58-
{configurationInfo?.http?.cors === false && (<ErrorComponent
59-
title="CORS Disabled: API Not Accessible"
60-
className="mt-0 mx-4 m-0"
61-
error={{
62-
message: `This ${clusterId && !instanceId ? 'cluster' : 'instance'} has CORS disabled currently, so you won't be able to execute API requests from the browser.`,
63-
}}
85+
{apiInaccessibleWarning && (<ErrorComponent
86+
title="CORS Disabled: HTTP API Not Accessible"
87+
className="mt-0 mx-4 m-0 border-yellow text-yellow"
88+
error={{ message: apiInaccessibleWarning }}
6489
showReturnToHome={false}
65-
/>)}
90+
>
91+
<Button disabled={isSettingConfiguration} variant="warning" className="rounded-full" onClick={enableCORS}>
92+
<Plus />
93+
{isSettingConfiguration ? 'Enabling CORS...' : `Enable CORS for ${window.location.origin}`}
94+
</Button>
95+
</ErrorComponent>)}
6696
<SwaggerUI
6797
spec={spec}
6898
persistAuthorization={true}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { InstanceClientConfig } from '@/config/instanceClientConfig';
2+
import { ReplicatedResponse } from '@/lib/api/replication';
3+
4+
interface SetConfigurationParams extends InstanceClientConfig {
5+
[key: string]: unknown;
6+
}
7+
8+
export async function setConfiguration({
9+
instanceClient,
10+
...changes
11+
}: SetConfigurationParams): Promise<ReplicatedResponse> {
12+
const { data } = await instanceClient.post('/', {
13+
operation: 'set_configuration',
14+
...changes,
15+
});
16+
return data;
17+
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { ProgressBar } from '@/components/ProgressBar';
2+
import { getInstanceClient } from '@/config/getInstanceClient';
3+
import { useInstanceClientIdParams } from '@/config/useInstanceClient';
4+
import { getClusterInfo } from '@/features/cluster/queries/getClusterInfoQuery';
5+
import { restartInstance } from '@/features/instance/operations/mutations/restartInstance';
6+
import { setConfiguration } from '@/features/instance/operations/mutations/setConfiguration';
7+
import { getInstanceUserInfo } from '@/features/instance/operations/queries/getInstanceUserInfo';
8+
import { Instance } from '@/lib/api.patch';
9+
import { excludeFalsy } from '@/lib/arrays/excludeFalsy';
10+
import { pluralize } from '@/lib/pluralize';
11+
import { sleep } from '@/lib/sleep';
12+
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';
13+
import { useQueryClient } from '@tanstack/react-query';
14+
import { useCallback, useState } from 'react';
15+
import { toast } from 'sonner';
16+
17+
interface RollingConfigUpdateParams {
18+
onRestartedSuccessfully?: () => void;
19+
}
20+
21+
interface RollingConfigUpdateResponse {
22+
onConfigUpdate: (params: Record<string, unknown>) => void;
23+
isPending: boolean;
24+
}
25+
26+
export function useRollingConfigUpdate({ onRestartedSuccessfully }: RollingConfigUpdateParams = {}): RollingConfigUpdateResponse {
27+
const operationsParams = useInstanceClientIdParams();
28+
const queryClient = useQueryClient();
29+
const [isPending, setIsPending] = useState(false);
30+
const onConfigUpdate = useCallback(async (params: Record<string, unknown>) => {
31+
setIsPending(true);
32+
33+
let canceled = false;
34+
const toastConfig = {
35+
duration: 60_000,
36+
action: {
37+
label: 'Cancel',
38+
onClick: () => {
39+
canceled = true;
40+
},
41+
},
42+
};
43+
44+
const toastId = toast.loading('Restarting', {
45+
...toastConfig,
46+
description: <ProgressBar
47+
animated={true}
48+
width="0%"
49+
/>,
50+
});
51+
52+
const cluster = operationsParams.entityType === 'cluster'
53+
? await getClusterInfo(operationsParams.entityId)
54+
: null;
55+
const allInstances = operationsParams.entityType === 'cluster'
56+
? cluster?.instances || []
57+
: [{ id: operationsParams.entityId, status: 'RUNNING' } as Instance];
58+
const instanceClients = operationsParams.entityType === 'cluster'
59+
? allInstances
60+
.filter(instance => instance.status === 'RUNNING')
61+
.map(instance => getInstanceClient({
62+
id: instance.id,
63+
operationsUrl: getOperationsUrlForInstance(instance),
64+
}))
65+
.reverse()
66+
: [operationsParams.instanceClient];
67+
let instancesRestarted = 0;
68+
69+
if (instanceClients.length) {
70+
for (let i = 0; i < instanceClients.length; i++) {
71+
const instanceClient = instanceClients[i];
72+
if (!canceled) {
73+
toast.loading(`Updating Instance ${i + 1} of ${instanceClients.length}`, {
74+
...toastConfig,
75+
id: toastId,
76+
description: <ProgressBar
77+
animated={true}
78+
width={(i === 0 ? 0 : (i / instanceClients.length * 100)) + '%'}
79+
/>,
80+
});
81+
try {
82+
// Make sure the instance is responding.
83+
await getInstanceUserInfo({
84+
instanceClient,
85+
});
86+
87+
await setConfiguration({
88+
...params,
89+
instanceClient,
90+
});
91+
// Then restart it.
92+
await restartInstance({
93+
operation: 'restart_service',
94+
replicated: false,
95+
instanceClient,
96+
});
97+
instancesRestarted += 1;
98+
} catch {
99+
if (i + 1 !== instanceClients.length) {
100+
// If it fails to restart, or wasn't available, warn for a bit then move on.
101+
toast.loading(`Failed Restarting Instance ${i + 1} of ${instanceClients.length}`, {
102+
...toastConfig,
103+
id: toastId,
104+
description: 'We will carry on momentarily.',
105+
});
106+
await sleep(3000);
107+
}
108+
}
109+
}
110+
}
111+
}
112+
113+
setIsPending(false);
114+
void queryClient.invalidateQueries({ queryKey: [operationsParams.entityId, 'get_configuration'] });
115+
116+
if (canceled) {
117+
toast.error('Cancelled', {
118+
id: toastId,
119+
description: `The config update was partially cancelled.`,
120+
duration: 10_000,
121+
action: {
122+
label: 'Dismiss',
123+
onClick: () => toast.dismiss(),
124+
},
125+
});
126+
} else if (allInstances.length === instancesRestarted) {
127+
onRestartedSuccessfully?.();
128+
toast.success('Success', {
129+
id: toastId,
130+
description: `Your configuration has been updated successfully!`,
131+
duration: 10_000,
132+
action: {
133+
label: 'Dismiss',
134+
onClick: () => toast.dismiss(),
135+
},
136+
});
137+
} else {
138+
const allTheInstances = pluralize(allInstances.length, 'instance', 'instances');
139+
const someRunningInstancesWere = pluralize(
140+
instancesRestarted,
141+
'"RUNNING" instance was',
142+
'"RUNNING" instances were',
143+
);
144+
toast.error('Error', {
145+
id: toastId,
146+
description: `Failed to fully update cluster.\n`
147+
+ ([
148+
allInstances.length === 0 && 'No instances were found within the cluster to restart.',
149+
instancesRestarted === 0 && `No instances were in a "RUNNING" state of ${allTheInstances}.`,
150+
allInstances.length !== instancesRestarted && `Only ${someRunningInstancesWere} restarted of ${allTheInstances}.`,
151+
].filter(excludeFalsy).shift() || ''),
152+
duration: 10_000,
153+
action: {
154+
label: 'Dismiss',
155+
onClick: () => toast.dismiss(),
156+
},
157+
});
158+
}
159+
}, [operationsParams, onRestartedSuccessfully]);
160+
161+
return {
162+
onConfigUpdate,
163+
isPending,
164+
};
165+
}

0 commit comments

Comments
 (0)