1import {
2 Badge,
3 Box,
4 Button,
5 chakra,
6 CircularProgress,
7 FormControl,
8 FormLabel,
9 HStack,
10 IconButton,
11 Modal,
12 ModalBody,
13 ModalCloseButton,
14 ModalContent,
15 ModalFooter,
16 ModalHeader,
17 ModalOverlay,
18 Text,
19 Tooltip,
20 useToast,
21} from '@chakra-ui/react';
22import {
23 ArrowPathIcon,
24 ArrowsPointingInIcon,
25 ArrowsPointingOutIcon,
26 Cog6ToothIcon,
27} from '@heroicons/react/24/outline';
28import { joinPaths } from '@remix-run/router';
29import classNames from 'classnames';
30import { useNodes, useCoreSettings } from 'stores';
31import debounce from 'lodash.debounce';
32import { FC, useCallback, useEffect, useRef, useState } from 'react';
33import { Controller, useForm } from 'react-hook-form';
34import { useTranslation } from 'react-i18next';
35import { useMutation } from '@tanstack/react-query';
36import { ReadyState } from 'react-use-websocket';
37import { useWebSocket } from 'react-use-websocket/dist/lib/use-websocket';
38import { getAuthToken } from 'service/auth-storage';
39import { Icon } from 'components/icon';
40import { JsonEditor } from 'components/json-editor';
41import 'components/json-editor/themes.js';
42
43export const MAX_NUMBER_OF_LOGS = 500;
44
45const UsageIcon = chakra(Cog6ToothIcon, {
46 baseStyle: {
47 w: 5,
48 h: 5,
49 },
50});
51export const ReloadIcon = chakra(ArrowPathIcon, {
52 baseStyle: {
53 w: 4,
54 h: 4,
55 },
56});
57
58export const FullScreenIcon = chakra(ArrowsPointingOutIcon, {
59 baseStyle: {
60 w: 4,
61 h: 4,
62 },
63});
64export const ExitFullScreenIcon = chakra(ArrowsPointingInIcon, {
65 baseStyle: {
66 w: 3,
67 h: 3,
68 },
69});
70
71const getStatus = (status: string) => {
72 return {
73 [ReadyState.CONNECTING]: 'connecting',
74 [ReadyState.OPEN]: 'connected',
75 [ReadyState.CLOSING]: 'closed',
76 [ReadyState.CLOSED]: 'closed',
77 [ReadyState.UNINSTANTIATED]: 'closed',
78 }[status];
79};
80
81const getWebsocketUrl = () => {
82 try {
83 let baseURL = new URL(
84 import.meta.env.VITE_BASE_API.startsWith('/')
85 ? window.location.origin + import.meta.env.VITE_BASE_API
86 : import.meta.env.VITE_BASE_API
87 );
88
89 return (
90 (baseURL.protocol === 'https:' ? 'wss://' : 'ws://') +
91 joinPaths([baseURL.host + baseURL.pathname, '/core/logs']) +
92 '?interval=1&token=' +
93 getAuthToken()
94 );
95 } catch (e) {
96 console.error('Unable to generate websocket url');
97 console.error(e);
98 return null;
99 }
100};
101
102let logsTmp: string[] = [];
103const CoreSettingModalContent: FC = () => {
104 const { isEditingCore } = useNodes();
105 const {
106 fetchCoreSettings,
107 updateConfig,
108 isLoading,
109 config,
110 isPostLoading,
111 version,
112 restartCore,
113 } = useCoreSettings();
114 const logsDiv = useRef<HTMLDivElement | null>(null);
115 const [logs, setLogs] = useState<string[]>([]);
116 const { t } = useTranslation();
117 const toast = useToast();
118 const form = useForm({
119 defaultValues: { config: config || {} },
120 });
121
122 useEffect(() => {
123 if (config) form.setValue('config', config);
124 }, [config]);
125
126 useEffect(() => {
127 if (isEditingCore) fetchCoreSettings();
128 }, [isEditingCore]);
129 ''.startsWith;
130 const scrollShouldStayOnEnd = useRef(true);
131 const updateLogs = useCallback(
132 debounce((logs: string[]) => {
133 const isScrollOnEnd =
134 Math.abs(
135 (logsDiv.current?.scrollTop || 0) -
136 (logsDiv.current?.scrollHeight || 0) +
137 (logsDiv.current?.offsetHeight || 0)
138 ) < 10;
139 if (logsDiv.current && isScrollOnEnd)
140 scrollShouldStayOnEnd.current = true;
141 else scrollShouldStayOnEnd.current = false;
142 if (logs.length < 40) setLogs(logs);
143 }, 300),
144 []
145 );
146
147 const { readyState } = useWebSocket(getWebsocketUrl(), {
148 onMessage: (e: any) => {
149 logsTmp.push(e.data);
150 if (logsTmp.length > MAX_NUMBER_OF_LOGS)
151 logsTmp = logsTmp.splice(0, logsTmp.length - MAX_NUMBER_OF_LOGS);
152 updateLogs([...logsTmp]);
153 },
154 shouldReconnect: () => true,
155 reconnectAttempts: 10,
156 reconnectInterval: 1000,
157 });
158
159 useEffect(() => {
160 if (logsDiv.current && scrollShouldStayOnEnd.current)
161 logsDiv.current.scrollTop = logsDiv.current?.scrollHeight;
162 }, [logs]);
163
164 useEffect(() => {
165 return () => {
166 logsTmp = [];
167 };
168 }, []);
169
170 const status = getStatus(readyState.toString());
171
172 const {
173 mutate: handleRestartCore,
174 isPending: isRestarting
175 } = useMutation({ mutationFn: restartCore });
176
177 const handleOnSave = ({ config }: any) => {
178 updateConfig(config)
179 .then(() => {
180 toast({
181 title: t('core.successMessage'),
182 status: 'success',
183 isClosable: true,
184 position: 'top',
185 duration: 3000,
186 });
187 })
188 .catch((e) => {
189 let message = t('core.generalErrorMessage');
190 if (typeof e.response._data.detail === 'object')
191 message =
192 e.response._data.detail[Object.keys(e.response._data.detail)[0]];
193 if (typeof e.response._data.detail === 'string')
194 message = e.response._data.detail;
195
196 toast({
197 title: message,
198 status: 'error',
199 isClosable: true,
200 position: 'top',
201 duration: 3000,
202 });
203 });
204 };
205 const editorRef = useRef<HTMLDivElement>(null);
206 const [isFullScreen, setFullScreen] = useState(false);
207 const handleFullScreen = () => {
208 if (document.fullscreenElement) {
209 document.exitFullscreen();
210 setFullScreen(false);
211 } else {
212 editorRef.current?.requestFullscreen();
213 setFullScreen(true);
214 }
215 };
216 return (
217 <form onSubmit={form.handleSubmit(handleOnSave)}>
218 <ModalBody>
219 <FormControl>
220 <HStack justifyContent="space-between" alignItems="flex-start">
221 <FormLabel>
222 {t('core.configuration')}{' '}
223 {isLoading && <CircularProgress isIndeterminate size="15px" />}
224 </FormLabel>
225 <HStack gap={0}>
226 <Tooltip label="Xray Version" placement="top">
227 <Badge height="100%" textTransform="lowercase">
228 {version && `v${version}`}
229 </Badge>
230 </Tooltip>
231 </HStack>
232 </HStack>
233 <Box position="relative" ref={editorRef} minHeight="300px">
234 <Controller
235 control={form.control}
236 name="config"
237 render={({ field }) => (
238 <JsonEditor json={config} onChange={field.onChange} />
239 )}
240 />
241 <IconButton
242 size="xs"
243 aria-label="full screen"
244 variant="ghost"
245 position="absolute"
246 top="2"
247 right="4"
248 onClick={handleFullScreen}
249 >
250 {!isFullScreen ? <FullScreenIcon /> : <ExitFullScreenIcon />}
251 </IconButton>
252 </Box>
253 </FormControl>
254 <FormControl mt="4">
255 <HStack justifyContent="space-between">
256 <FormLabel>{t('core.logs')}</FormLabel>
257 <Text as={FormLabel}>{t(`core.socket.${status}`)}</Text>
258 </HStack>
259 <Box
260 border="1px solid"
261 borderColor="gray.300"
262 bg="#F9F9F9"
263 _dark={{
264 borderColor: 'gray.500',
265 bg: '#2e3440',
266 }}
267 borderRadius={5}
268 minHeight="200px"
269 maxHeight={'250px'}
270 p={2}
271 overflowY="auto"
272 ref={logsDiv}
273 >
274 {logs.map((message, i) => (
275 <Text fontSize="xs" opacity={0.8} key={i} whiteSpace="pre-line">
276 {message}
277 </Text>
278 ))}
279 </Box>
280 </FormControl>
281 </ModalBody>
282 <ModalFooter>
283 <HStack w="full" justifyContent="space-between">
284 <Box>
285 <Button
286 size="sm"
287 leftIcon={
288 <ReloadIcon
289 className={classNames({
290 'animate-spin': isRestarting,
291 })}
292 />
293 }
294 onClick={() => handleRestartCore()}
295 >
296 {t(isRestarting ? 'core.restarting' : 'core.restartCore')}
297 </Button>
298 </Box>
299 <HStack>
300 <Button
301 size="sm"
302 variant="solid"
303 colorScheme="primary"
304 px="5"
305 type="submit"
306 isDisabled={isLoading || isPostLoading}
307 isLoading={isPostLoading}
308 >
309 {t('core.save')}
310 </Button>
311 </HStack>
312 </HStack>
313 </ModalFooter>
314 </form>
315 );
316};
317export const CoreSettingsModal: FC = () => {
318 const { isEditingCore } = useNodes();
319 const onClose = useNodes.setState.bind(null, { isEditingCore: false });
320 const { t } = useTranslation();
321
322 return (
323 <Modal isOpen={isEditingCore} onClose={onClose} size="3xl">
324 <ModalOverlay bg="blackAlpha.300" backdropFilter="blur(10px)" />
325 <ModalContent mx="3" w="full">
326 <ModalHeader pt={6}>
327 <HStack gap={2}>
328 <Icon color="primary">
329 <UsageIcon color="white" />
330 </Icon>
331 <Text fontWeight="semibold" fontSize="lg">
332 {t('core.title')}
333 </Text>
334 </HStack>
335 </ModalHeader>
336 <ModalCloseButton mt={3} />
337 <CoreSettingModalContent />
338 </ModalContent>
339 </Modal>
340 );
341};